<?php
/**
 * DBA_Anti_Orphan_Injector — Contextual Link Injection Engine
 *
 * ═══════════════════════════════════════════════════════════════════════
 *  PURPOSE: Fix orphan pages by injecting internal links FROM other
 *  same-language pages TO the orphan. Uses MULTI-SIGNAL composite scoring:
 *    - TF-IDF cosine similarity (slug+title, content body)
 *    - WordPress category/tag overlap
 *    - Post type matching
 *
 *  Zero external API calls — everything computed in pure PHP.
 * ═══════════════════════════════════════════════════════════════════════
 *
 *  STRATEGY (different from auto-linker):
 *    The auto-linker adds links INTO a new post pointing to existing pages.
 *    The anti-orphan injector does the REVERSE: it finds existing pages
 *    and adds links INTO THEM that point to the orphan page.
 *
 *  WPML SAFETY:
 *    - ONLY injects links between pages of the SAME language
 *    - Verifies language match via icl_translations before any injection
 *
 *  COMPOSITE SCORING FORMULA:
 *    final_score = (0.40 × slug_title_cosine)
 *                + (0.30 × content_cosine)
 *                + (0.15 × category_bonus)   [1.0 if shared category, 0.0 if none]
 *                + (0.10 × tag_bonus)         [1.0 if shared tag, 0.0 if none]
 *                + (0.05 × type_bonus)        [1.0 if same type, 0.5 if different]
 *
 *  PERFORMANCE SAFEGUARDS:
 *    - Pre-fetch categories/tags in 2 SQL queries (not per-post)
 *    - Keyword pre-filter then TF-IDF scoring on all candidates
 *    - Early exit on 6 good donors (score ≥ 0.30)
 *    - 3-level throttle: micro-pause, chunk-pause, emergency brake
 *    - Resumable: skips orphans fixed within 7 days
 *
 * @since 2.18.0
 */

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

class DBA_Anti_Orphan_Injector {

    /** Meta key to track injected anti-orphan links */
    const META_INJECTED = '_dba_anti_orphan_injected';

    /** Meta key on donor posts to track what was injected into them */
    const META_DONOR = '_dba_anti_orphan_donor_for';

    /** Option key for injection log */
    const LOG_KEY = 'dba_anti_orphan_injection_log';

    /** Composite score threshold */
    const MIN_SIMILARITY = 0.12;

    /** Minimum paragraph length to consider for injection */
    const MIN_PARA_CHARS = 140;

    /* ── Composite scoring weights ── */
    const W_SLUG_TITLE = 0.40;
    const W_CONTENT    = 0.30;
    const W_CATEGORY   = 0.15;
    const W_TAG        = 0.10;
    const W_TYPE       = 0.05;

    /** Max candidates to evaluate per orphan */
    const MAX_CANDIDATES = 2000;

    /** Noise words */
    private static $noise_words = array(
        'the','and','for','with','from','this','that','into','about','also',
        'how','what','why','when','where','are','was','were','been','have','has',
        'will','can','may','should','would','could','its','our','your','their',
        'a','an','in','on','at','to','of','by','as','is','it','or','be','do','no',
        'not','but','all','more','some','such','than','too','very','just',
        'set','up','per','via','use','new','way','get','let','try','run','put',
        'see','own','out','off','yet','so','if','my','we','he','she','they',
        'these','those','which','who','whom','each','every','both','other',
        'make','made','like','many','much','well','most','only','over','must',
        'here','there','then','now','them','been','being','does','did','had',
        'two','one','first','last','next','after','before','between','through',
        'under','while','during','without','within','among','across','along',
    );

    /* ══════════════════════════════════════════════════════════════
     *  MAIN FIX — Fix a single orphan page
     * ══════════════════════════════════════════════════════════════ */

    public static function fix_orphan( $orphan_id, $lang, $max_links = 2 ) {
        $orphan_post = get_post( $orphan_id );
        if ( ! $orphan_post || $orphan_post->post_status !== 'publish' ) {
            return array( 'success' => false, 'error' => 'Post not found or not published.' );
        }

        $orphan_url   = get_permalink( $orphan_id );
        $orphan_title = $orphan_post->post_title;

        if ( ! $orphan_url ) {
            return array( 'success' => false, 'error' => 'Could not get permalink.' );
        }

        // Verify language
        $post_lang = self::get_post_language( $orphan_id );
        if ( $post_lang && $post_lang !== $lang ) {
            return array( 'success' => false, 'error' => "Language mismatch: post is {$post_lang}, expected {$lang}." );
        }

        // Content-based language mismatch detector
        $script_mismatch = self::detect_script_mismatch( $orphan_post, $lang );
        if ( $script_mismatch ) {
            return array( 'success' => false, 'error' => "Script mismatch: {$script_mismatch}" );
        }

        // Find the best donor pages (same language, high composite score)
        $donors = self::find_donor_pages( $orphan_id, $orphan_post, $lang, $max_links );

        if ( empty( $donors ) ) {
            return array( 'success' => false, 'error' => 'No suitable donor pages found.', 'links_injected' => 0 );
        }

        $injected = array();

        foreach ( $donors as $donor ) {
            // SAFETY: Double-check donor language
            $donor_lang = self::get_post_language( $donor['id'] );
            if ( $donor_lang && $donor_lang !== $lang ) {
                error_log( "DBA Anti-Orphan SAFETY: Donor #{$donor['id']} is {$donor_lang}, expected {$lang}. Skipping." );
                continue;
            }

            $result = self::inject_link_into_donor( $donor['id'], $orphan_url, $orphan_title, $orphan_id, $donor['score'] );

            if ( $result['success'] ) {
                $injected[] = array(
                    'donor_id'    => $donor['id'],
                    'donor_title' => get_the_title( $donor['id'] ),
                    'donor_url'   => get_permalink( $donor['id'] ),
                    'similarity'  => $donor['score'],
                    'anchor'      => $result['anchor'],
                    'method'      => isset( $result['method'] ) ? $result['method'] : '',
                );
            }

            if ( count( $injected ) >= $max_links ) break;
        }

        // Track injection on the orphan
        update_post_meta( $orphan_id, self::META_INJECTED, array(
            'date'    => current_time( 'mysql' ),
            'donors'  => $injected,
            'lang'    => $lang,
        ) );

        // Log
        self::log_injection( $orphan_id, $orphan_url, $lang, $injected );

        return array(
            'success'        => count( $injected ) > 0,
            'links_injected' => count( $injected ),
            'donors'         => $injected,
        );
    }

    /* ══════════════════════════════════════════════════════════════
     *  BATCH FIX — Fix all orphans (with 3-level throttling)
     * ══════════════════════════════════════════════════════════════ */

    public static function fix_all_orphans( $lang, $max_links = 2, $limit = 50 ) {
        $orphans = DBA_Anti_Orphan_Scanner::get_orphans( $lang );

        if ( empty( $orphans ) ) {
            return array( 'total_orphans' => 0, 'fixed' => 0, 'failed' => 0, 'results' => array() );
        }

        $results     = array();
        $fixed       = 0;
        $failed      = 0;
        $processed   = 0;
        $skipped     = 0;
        $stopped     = false;
        $stop_reason = '';

        // Emergency brake limits
        $start_time = time();
        $time_limit = 60; // 60 seconds — safe margin under Cloudflare 100s timeout
        $mem_limit  = DBA_Anti_Orphan_Scanner::get_memory_limit_bytes() * 0.80;

        foreach ( $orphans as $orphan ) {
            if ( $processed >= $limit ) break;

            // ── LEVEL 3: Emergency Brake ──
            if ( memory_get_usage( true ) > $mem_limit ) {
                $stopped = true;
                $stop_reason = 'Memory limit approaching (' . size_format( memory_get_usage( true ) ) . ')';
                error_log( 'DBA Anti-Orphan fix_all: STOPPED — ' . $stop_reason );
                break;
            }
            if ( ( time() - $start_time ) > $time_limit ) {
                $stopped = true;
                $stop_reason = 'Time limit (60s) reached';
                error_log( 'DBA Anti-Orphan fix_all: STOPPED — ' . $stop_reason );
                break;
            }

            // Skip if already fixed AND has active injected links (check meta exists with donors)
            $prev = get_post_meta( $orphan['id'], self::META_INJECTED, true );
            if ( ! empty( $prev['donors'] ) && count( $prev['donors'] ) >= $max_links ) {
                $skipped++;
                continue;
            }

            $result = self::fix_orphan( $orphan['id'], $lang, $max_links );

            $results[] = array(
                'orphan_id'    => $orphan['id'],
                'orphan_title' => $orphan['title'],
                'orphan_url'   => $orphan['url'],
                'result'       => $result,
            );

            if ( $result['success'] ) {
                $fixed++;
            } else {
                $failed++;
            }

            $processed++;

            // ── Cache flush every 20 orphans to prevent memory bloat ──
            if ( $processed % 20 === 0 ) {
                if ( function_exists( 'wp_cache_flush' ) ) wp_cache_flush();
                usleep( 200000 ); // 0.2s breather
            }
        }

        // Collect samples of fixed posts for UI verification
        $samples = array();
        foreach ( $results as $r ) {
            if ( $r['result']['success'] && count( $samples ) < 5 ) {
                $method = 'unknown';
                if ( ! empty( $r['result']['donors'] ) ) {
                    $first = $r['result']['donors'][0];
                    $method = isset( $first['method'] ) ? $first['method'] : 'injected';
                }
                $samples[] = array(
                    'orphan_id'    => $r['orphan_id'],
                    'orphan_title' => $r['orphan_title'],
                    'orphan_url'   => $r['orphan_url'],
                    'method'       => $method,
                    'anchor'       => isset( $r['result']['donors'][0]['anchor'] ) ? $r['result']['donors'][0]['anchor'] : '',
                );
            }
        }

        return array(
            'total_orphans' => count( $orphans ),
            'fixed'         => $fixed,
            'failed'        => $failed,
            'skipped'       => $skipped,
            'stopped'       => $stopped,
            'stop_reason'   => $stop_reason,
            'remaining'     => max( 0, count( $orphans ) - $processed - $skipped ),
            'samples'       => $samples,
        );
    }

    /* ══════════════════════════════════════════════════════════════
     *  DONOR PAGE DISCOVERY — Multi-Signal Composite Scoring
     * ══════════════════════════════════════════════════════════════ */

    private static function find_donor_pages( $orphan_id, $orphan_post, $lang, $max_results = 4 ) {
        global $wpdb;

        $wpml_active = defined( 'ICL_SITEPRESS_VERSION' ) || function_exists( 'icl_get_languages' );

        // Get all same-language posts
        $candidates = DBA_Anti_Orphan_Scanner::get_posts_for_language( $lang, $wpml_active );

        if ( empty( $candidates ) ) return array();

        // No date-sort or cap needed — PHASE 1 keyword pre-filter handles performance

        // ── Build orphan vectors ──
        $orphan_url         = get_permalink( $orphan_id );
        $orphan_url_clean   = strtolower( rtrim( $orphan_url, '/' ) );
        $orphan_slug_words  = self::slug_words_from_url( $orphan_url );
        $orphan_title_words = self::tokenize( strtolower( $orphan_post->post_title ) );

        // Key terms = slug + title words (deduplicated, min 3 chars)
        $orphan_key_terms = array_unique( array_filter(
            array_merge( $orphan_slug_words, $orphan_title_words ),
            function( $w ) { return strlen( $w ) >= 2; }
        ) );

        // ── PHASE 1: Quick keyword pre-filter ──
        // Only do full TF-IDF on candidates that share ≥1 key term with orphan
        $filtered = array();
        foreach ( $candidates as $cand ) {
            if ( $cand->ID == $orphan_id ) continue;

            // Skip too-short content (lowered from 500 to accept shorter service pages)
            if ( strlen( $cand->post_content ) < 200 ) continue;

            // Skip if not enough paragraphs (lowered: accept pages with divs etc.)
            $p_count = substr_count( strtolower( $cand->post_content ), '<p' )
                     + substr_count( strtolower( $cand->post_content ), '<div' );
            if ( $p_count < 2 ) continue;

            // Skip if already links to orphan
            if ( $orphan_url && strpos( $cand->post_content, $orphan_url ) !== false ) continue;
            if ( strpos( $cand->post_content, $orphan_url_clean ) !== false ) continue;

            // Quick keyword check: does title or slug share any key term?
            $cand_title_lower = strtolower( $cand->post_title . ' ' . $cand->post_name );
            $shared = 0;
            foreach ( $orphan_key_terms as $term ) {
                if ( strpos( $cand_title_lower, $term ) !== false ) {
                    $shared++;
                }
            }

            // Accept if shares ≥1 keyword, OR is in same category (checked later)
            if ( $shared > 0 ) {
                $cand->_keyword_boost = $shared;
                $filtered[] = $cand;
            } elseif ( count( $filtered ) < 50 ) {
                // Also include some non-matching for diversity (capped at 50)
                $cand->_keyword_boost = 0;
                $filtered[] = $cand;
            }
        }

        // ── PHASE 2: Full TF-IDF scoring on filtered candidates ──
        if ( empty( $filtered ) ) return array();

        // Slug+Title vector (orphan: slug×3 + title×2)
        $orphan_st_vector = array();
        for ( $i = 0; $i < 3; $i++ ) $orphan_st_vector = array_merge( $orphan_st_vector, $orphan_slug_words );
        for ( $i = 0; $i < 2; $i++ ) $orphan_st_vector = array_merge( $orphan_st_vector, $orphan_title_words );

        // Content vector (first 3000 chars + title + slug for enrichment)
        $orphan_content_text = wp_strip_all_tags( substr( $orphan_post->post_content, 0, 3000 ) );
        $orphan_c_vector = self::tokenize( strtolower(
            $orphan_content_text . ' ' . $orphan_post->post_title . ' ' . implode( ' ', $orphan_slug_words )
        ) );

        if ( empty( $orphan_st_vector ) && empty( $orphan_c_vector ) ) return array();

        // ── Pre-fetch categories and tags for filtered candidates (2 queries total) ──
        $all_ids   = array_map( function( $c ) { return $c->ID; }, $filtered );
        $all_ids[] = $orphan_id;
        $id_list   = implode( ',', array_map( 'intval', $all_ids ) );

        $cat_map = array();
        $tag_map = array();

        // Categories — single query
        $cat_rows = $wpdb->get_results(
            "SELECT tr.object_id, tr.term_taxonomy_id
             FROM {$wpdb->term_relationships} tr
             INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
             WHERE tr.object_id IN ({$id_list})
               AND tt.taxonomy = 'category'"
        );
        foreach ( $cat_rows as $row ) {
            $cat_map[ $row->object_id ][] = (int) $row->term_taxonomy_id;
        }

        // Tags — single query
        $tag_rows = $wpdb->get_results(
            "SELECT tr.object_id, tr.term_taxonomy_id
             FROM {$wpdb->term_relationships} tr
             INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
             WHERE tr.object_id IN ({$id_list})
               AND tt.taxonomy = 'post_tag'"
        );
        foreach ( $tag_rows as $row ) {
            $tag_map[ $row->object_id ][] = (int) $row->term_taxonomy_id;
        }

        $orphan_cats = isset( $cat_map[ $orphan_id ] ) ? $cat_map[ $orphan_id ] : array();
        $orphan_tags = isset( $tag_map[ $orphan_id ] ) ? $tag_map[ $orphan_id ] : array();

        // ── Score each candidate ──
        $scored = array();
        $good_donor_count = 0;

        foreach ( $filtered as $cand ) {

            // ── Signal 1: Slug+Title cosine (40%) ──
            $cand_slug_words  = self::slug_words_from_url( get_permalink( $cand->ID ) );
            $cand_title_words = self::tokenize( strtolower( $cand->post_title ) );
            $cand_st_vector   = $cand_title_words;
            for ( $r = 0; $r < 3; $r++ ) {
                $cand_st_vector = array_merge( $cand_st_vector, $cand_slug_words );
            }
            $slug_title_score = self::tfidf_cosine( $orphan_st_vector, $cand_st_vector );

            // ── Signal 2: Content body cosine (30%) ──
            $cand_content_text = wp_strip_all_tags( substr( $cand->post_content, 0, 2000 ) );
            $cand_c_vector = self::tokenize( strtolower(
                $cand_content_text . ' ' . $cand->post_title . ' ' . implode( ' ', $cand_slug_words )
            ) );
            $content_score = self::tfidf_cosine( $orphan_c_vector, $cand_c_vector );

            // ── Signal 3: Category match (15%) ──
            $cand_cats = isset( $cat_map[ $cand->ID ] ) ? $cat_map[ $cand->ID ] : array();
            $cat_bonus = ( ! empty( $orphan_cats ) && ! empty( $cand_cats )
                           && ! empty( array_intersect( $orphan_cats, $cand_cats ) ) ) ? 1.0 : 0.0;

            // ── Signal 4: Tag match (10%) ──
            $cand_tags = isset( $tag_map[ $cand->ID ] ) ? $tag_map[ $cand->ID ] : array();
            $tag_bonus = ( ! empty( $orphan_tags ) && ! empty( $cand_tags )
                           && ! empty( array_intersect( $orphan_tags, $cand_tags ) ) ) ? 1.0 : 0.0;

            // ── Signal 5: Post type match (5%) ──
            $type_bonus = ( $cand->post_type === $orphan_post->post_type ) ? 1.0 : 0.5;

            // ── Composite score ──
            $final_score = ( self::W_SLUG_TITLE * $slug_title_score )
                         + ( self::W_CONTENT    * $content_score )
                         + ( self::W_CATEGORY   * $cat_bonus )
                         + ( self::W_TAG        * $tag_bonus )
                         + ( self::W_TYPE       * $type_bonus );

            if ( $final_score >= self::MIN_SIMILARITY ) {
                $scored[] = array(
                    'id'    => $cand->ID,
                    'score' => round( $final_score, 4 ),
                    'title' => $cand->post_title,
                );

                // Early exit: stop once we have 6 good donors
                if ( $final_score >= 0.30 ) {
                    $good_donor_count++;
                    if ( $good_donor_count >= 6 ) break;
                }
            }
        }

        // Sort by score descending
        usort( $scored, function( $a, $b ) {
            return ( $b['score'] <=> $a['score'] );
        });

        return array_slice( $scored, 0, $max_results * 2 );
    }

    /* ══════════════════════════════════════════════════════════════
     *  LINK INJECTION — Insert a link into a donor post
     * ══════════════════════════════════════════════════════════════ */

    private static function inject_link_into_donor( $donor_id, $orphan_url, $orphan_title, $orphan_id, $donor_score = 0 ) {
        $donor_post = get_post( $donor_id );
        if ( ! $donor_post ) {
            return array( 'success' => false, 'anchor' => '' );
        }

        $content = $donor_post->post_content;

        // Double-check the link doesn't already exist
        if ( strpos( $content, $orphan_url ) !== false ) {
            return array( 'success' => false, 'anchor' => '' );
        }
        $orphan_url_no_slash = rtrim( $orphan_url, '/' );
        if ( strpos( $content, $orphan_url_no_slash ) !== false ) {
            return array( 'success' => false, 'anchor' => '' );
        }

        // Build anchor text early
        $anchor = self::shorten_title( $orphan_title );
        if ( empty( $anchor ) || strlen( $anchor ) < 3 ) {
            $anchor = $orphan_title;
        }

        // ── METHOD 1: Find best paragraph to inject inline link ──
        $orphan_slug_words  = self::slug_words_from_url( $orphan_url );
        $orphan_title_words = self::tokenize( strtolower( $orphan_title ) );
        $orphan_words       = array_merge( $orphan_title_words, $orphan_slug_words, $orphan_slug_words );

        $best_para  = null;
        $best_score = 0;
        $best_match = null;
        $best_tag   = 'p';

        // Search BOTH <p> and <div> tags
        if ( preg_match_all( '/<(p|div)([^>]*)>(.*?)<\/\1>/si', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) {
            foreach ( $matches as $m ) {
                $para_html  = $m[0][0];
                $tag_name   = $m[1][0];
                $para_inner = $m[3][0];
                $plain      = wp_strip_all_tags( $para_inner );

                // Min 60 chars (was 140)
                if ( strlen( $plain ) < 60 ) continue;

                // Skip CTA paragraphs only
                $lower = strtolower( $para_inner );
                if ( strpos( $lower, 'lets-connect' ) !== false ) continue;
                if ( strpos( $lower, 'contact your' ) !== false ) continue;
                if ( strpos( $lower, 'damalion expert' ) !== false ) continue;
                if ( strpos( $lower, 'contact us' ) !== false ) continue;

                // Only skip if paragraph links to THIS orphan (not other internal links)
                if ( strpos( $para_inner, $orphan_url ) !== false ) continue;
                if ( strpos( $para_inner, $orphan_url_no_slash ) !== false ) continue;

                $para_words = self::tokenize( strtolower( $plain ) );
                if ( count( $para_words ) < 3 ) continue;

                $score = self::tfidf_cosine( $orphan_words, $para_words );

                if ( $score > $best_score ) {
                    $best_score = $score;
                    $best_para  = $para_html;
                    $best_match = $m;
                    $best_tag   = $tag_name;
                }
            }
        }

        $method = '';

        // Try inline injection if we found a paragraph (score >= 0.01, was 0.03)
        if ( $best_para && $best_score >= 0.01 && $best_match ) {
            $para_inner = $best_match[3][0];

            // Try smart anchor from paragraph context
            $smart_anchor = self::build_anchor_text( $orphan_url, $orphan_title, wp_strip_all_tags( $para_inner ) );
            if ( ! empty( $smart_anchor ) && strlen( $smart_anchor ) >= 3 ) {
                $anchor = $smart_anchor;
            }

            // Try wrapping existing text in the paragraph
            $pos = stripos( wp_strip_all_tags( $para_inner ), $anchor );
            if ( $pos !== false ) {
                $actual_text = substr( wp_strip_all_tags( $para_inner ), $pos, strlen( $anchor ) );
                $new_inner = self::wrap_first_occurrence( $para_inner, $actual_text, $orphan_url, $orphan_title );

                if ( $new_inner !== $para_inner ) {
                    $new_para    = str_replace( $para_inner, $new_inner, $best_para );
                    $new_content = str_replace( $best_para, $new_para, $content );

                    if ( $new_content !== $content ) {
                        $method = 'inline_wrap';
                        $content = $new_content; // update for save
                    }
                }
            }

            // Fallback: append contextual sentence to best paragraph
            if ( empty( $method ) ) {
                $link_html = '<a href="' . esc_url( $orphan_url ) . '" title="' . esc_attr( $orphan_title ) . '">'
                    . esc_html( $anchor ) . '</a>';
                $contextual = self::build_contextual_sentence( $orphan_title, $link_html );
                $close_tag  = '</' . $best_tag . '>';
                $new_para   = str_replace( $close_tag, ' ' . $contextual . $close_tag, $best_para );

                if ( $new_para !== $best_para ) {
                    $new_content = str_replace( $best_para, $new_para, $content );
                    if ( $new_content !== $content ) {
                        $method  = 'sentence_append';
                        $content = $new_content;
                    }
                }
            }
        }

        // ── METHOD 2: "See also" fallback — only for strongly related donors (score ≥ 0.20) ──
        if ( empty( $method ) && $donor_score >= 0.20 ) {
            $link_html = '<a href="' . esc_url( $orphan_url ) . '" title="' . esc_attr( $orphan_title ) . '">'
                . esc_html( $anchor ) . '</a>';
            $see_also  = '<p class="dba-see-also" style="margin-top:1em;font-style:italic;">See also: '
                . $link_html . '</p>';

            // Find last closing tag to insert before
            $insert_pos = false;
            foreach ( array( '</div>', '</section>', '</article>' ) as $tag ) {
                $last = strrpos( $content, $tag );
                if ( $last !== false && ( $insert_pos === false || $last > $insert_pos ) ) {
                    $insert_pos = $last;
                }
            }

            if ( $insert_pos !== false ) {
                $new_content = substr( $content, 0, $insert_pos ) . $see_also . substr( $content, $insert_pos );
            } else {
                // Just append at the end
                $new_content = $content . "\n" . $see_also;
            }

            if ( $new_content !== $content ) {
                $method  = 'see_also';
                $content = $new_content;
            }
        }

        // ── SAVE ──
        if ( ! empty( $method ) ) {
            self::silent_update_post_content( $donor_id, $content );

            $existing_donors = get_post_meta( $donor_id, self::META_DONOR, true );
            if ( ! is_array( $existing_donors ) ) $existing_donors = array();
            $existing_donors[] = array(
                'orphan_id'  => $orphan_id,
                'orphan_url' => $orphan_url,
                'anchor'     => $anchor,
                'method'     => $method,
                'date'       => current_time( 'mysql' ),
            );
            update_post_meta( $donor_id, self::META_DONOR, $existing_donors );

            error_log( "DBA Anti-Orphan: [{$method}] link to {$orphan_url} in #{$donor_id} anchor='{$anchor}'" );
            return array( 'success' => true, 'anchor' => $anchor, 'method' => $method );
        }

        return array( 'success' => false, 'anchor' => '' );
    }

    /* ══════════════════════════════════════════════════════════════
     *  ANCHOR TEXT BUILDING
     * ══════════════════════════════════════════════════════════════ */

    private static function build_anchor_text( $url, $title, $paragraph_text ) {
        $slug_words = self::slug_words_from_url( $url );
        $title_words = self::tokenize( strtolower( $title ) );
        $para_lower = strtolower( $paragraph_text );

        // Try consecutive slug words in paragraph
        for ( $len = min( count( $slug_words ), 5 ); $len >= 2; $len-- ) {
            for ( $start = 0; $start <= count( $slug_words ) - $len; $start++ ) {
                $phrase = implode( ' ', array_slice( $slug_words, $start, $len ) );
                if ( stripos( $para_lower, $phrase ) !== false ) {
                    $pos = stripos( $paragraph_text, $phrase );
                    if ( $pos !== false ) {
                        return substr( $paragraph_text, $pos, strlen( $phrase ) );
                    }
                }
            }
        }

        // Try title phrases
        $title_parts = explode( ' ', strtolower( $title ) );
        for ( $len = min( count( $title_parts ), 5 ); $len >= 3; $len-- ) {
            for ( $start = 0; $start <= count( $title_parts ) - $len; $start++ ) {
                $phrase = implode( ' ', array_slice( $title_parts, $start, $len ) );
                if ( strlen( $phrase ) >= 10 && stripos( $para_lower, $phrase ) !== false ) {
                    $pos = stripos( $paragraph_text, $phrase );
                    if ( $pos !== false ) {
                        return substr( $paragraph_text, $pos, strlen( $phrase ) );
                    }
                }
            }
        }

        return self::shorten_title( $title );
    }

    private static function shorten_title( $title ) {
        $clean = preg_replace( '/\s*[-|–—]\s*(Damalion|Independent).*$/i', '', $title );
        $clean = trim( $clean );
        if ( strlen( $clean ) > 50 ) {
            $clean = substr( $clean, 0, 50 );
            $last_space = strrpos( $clean, ' ' );
            if ( $last_space > 20 ) {
                $clean = substr( $clean, 0, $last_space );
            }
        }
        return $clean;
    }

    private static function build_contextual_sentence( $title, $link_html ) {
        $patterns = array(
            'For further insights, see our guide on %s.',
            'Learn more about %s.',
            'You may also find our resource on %s helpful.',
            'Related: %s.',
        );
        $pattern = $patterns[ array_rand( $patterns ) ];
        return sprintf( $pattern, $link_html );
    }

    private static function wrap_first_occurrence( $html, $needle, $url, $title ) {
        $escaped = preg_quote( $needle, '/' );
        $pattern = '/(?<!["\'>])(' . $escaped . ')(?![^<]*<\/a>)/i';
        $replacement = '<a href="' . esc_url( $url ) . '" title="' . esc_attr( $title ) . '">$1</a>';
        $result = preg_replace( $pattern, $replacement, $html, 1 );
        return $result !== null ? $result : $html;
    }

    /* ══════════════════════════════════════════════════════════════
     *  ROLLBACK
     * ══════════════════════════════════════════════════════════════ */

    public static function rollback_orphan( $orphan_id ) {
        $meta = get_post_meta( $orphan_id, self::META_INJECTED, true );
        if ( empty( $meta['donors'] ) ) {
            // Even without meta, clean up the meta key
            delete_post_meta( $orphan_id, self::META_INJECTED );
            return array( 'success' => false, 'error' => 'No injection records found.', 'rolled_back_count' => 0 );
        }

        $rolled_back = 0;
        $orphan_url = get_permalink( $orphan_id );

        foreach ( $meta['donors'] as $donor_info ) {
            $donor_id = $donor_info['donor_id'];
            $donor_post = get_post( $donor_id );
            if ( ! $donor_post ) continue;
            if ( ! $orphan_url ) continue;

            $content = $donor_post->post_content;
            $orphan_url_noslash = rtrim( $orphan_url, '/' );

            // Build escaped URLs for regex (both with and without trailing slash)
            $escaped_url1 = preg_quote( $orphan_url, '/' );
            $escaped_url2 = preg_quote( $orphan_url_noslash, '/' );

            // 1. Remove <a> tags pointing to this orphan (inline_wrap and sentence_append)
            $new_content = preg_replace(
                '/<a\s+[^>]*href=["\'](?:' . $escaped_url1 . '|' . $escaped_url2 . ')\/?\s*["\'][^>]*>(.*?)<\/a>/si',
                '$1',
                $content
            );

            // 2. Remove "See also:" paragraphs with dba-see-also class
            $new_content = preg_replace(
                '/<p[^>]*class=["\']dba-see-also["\'][^>]*>.*?<\/p>\s*/si',
                '',
                $new_content
            );

            // 3. Remove contextual sentences that contain the orphan URL
            $new_content = preg_replace(
                '/\s*(?:For further insights, see our guide on|Learn more about|You may also find our resource on|Related:)\s*<a\s+[^>]*href=["\'](?:' . $escaped_url1 . '|' . $escaped_url2 . ')\/?\s*["\'][^>]*>.*?<\/a>\s*\.?\s*/si',
                '',
                $new_content
            );

            if ( $new_content !== $content ) {
                self::silent_update_post_content( $donor_id, $new_content );
                $rolled_back++;
            }

            // Clean donor meta
            $donor_meta = get_post_meta( $donor_id, self::META_DONOR, true );
            if ( is_array( $donor_meta ) ) {
                $donor_meta = array_filter( $donor_meta, function( $d ) use ( $orphan_id ) {
                    return $d['orphan_id'] != $orphan_id;
                } );
                if ( empty( $donor_meta ) ) {
                    delete_post_meta( $donor_id, self::META_DONOR );
                } else {
                    update_post_meta( $donor_id, self::META_DONOR, $donor_meta );
                }
            }
        }

        delete_post_meta( $orphan_id, self::META_INJECTED );

        return array( 'success' => true, 'rolled_back_count' => $rolled_back );
    }

    /* ══════════════════════════════════════════════════════════════
     *  TF-IDF COSINE SIMILARITY
     * ══════════════════════════════════════════════════════════════ */

    private static function tfidf_cosine( $words_a, $words_b ) {
        if ( empty( $words_a ) || empty( $words_b ) ) return 0.0;

        $freq_a = array_count_values( $words_a );
        $freq_b = array_count_values( $words_b );

        $shared = array_intersect_key( $freq_a, $freq_b );
        if ( empty( $shared ) ) return 0.0;

        $dot = 0.0;
        $mag_a_sq = 0.0;
        $mag_b_sq = 0.0;

        foreach ( $freq_a as $w => $c ) { $mag_a_sq += $c * $c; }
        foreach ( $freq_b as $w => $c ) { $mag_b_sq += $c * $c; }
        foreach ( $shared as $w => $c ) { $dot += $freq_a[ $w ] * $freq_b[ $w ]; }

        $mag_a = sqrt( $mag_a_sq );
        $mag_b = sqrt( $mag_b_sq );

        if ( $mag_a < 0.0001 || $mag_b < 0.0001 ) return 0.0;
        return $dot / ( $mag_a * $mag_b );
    }

    /* ══════════════════════════════════════════════════════════════
     *  TOKENIZATION
     * ══════════════════════════════════════════════════════════════ */

    private static function tokenize( $text ) {
        $words = preg_split( '/[^a-z0-9àâäéèêëïîôùûüÿçæœßñ]+/u', strtolower( $text ) );
        $result = array();
        foreach ( $words as $w ) {
            if ( strlen( $w ) >= 2 && ! in_array( $w, self::$noise_words ) && ! is_numeric( $w ) ) {
                $result[] = $w;
            }
        }
        return $result;
    }

    private static function slug_words_from_url( $url ) {
        $parsed = wp_parse_url( $url );
        $path = isset( $parsed['path'] ) ? trim( $parsed['path'], '/' ) : '';
        if ( empty( $path ) ) return array();
        $path = preg_replace( '#\d{4}/\d{2}/\d{2}/#', '', $path );
        $text = str_replace( array( '-', '_', '/' ), ' ', $path );
        return self::tokenize( $text );
    }

    /* ══════════════════════════════════════════════════════════════
     *  WPML-SAFE POST UPDATE
     *  Updates post_content directly via $wpdb, bypassing ALL
     *  save_post hooks so WPML never sees the change as a
     *  translation-worthy content modification.
     * ══════════════════════════════════════════════════════════════ */

    private static function silent_update_post_content( $post_id, $new_content ) {
        global $wpdb;

        $result = $wpdb->update(
            $wpdb->posts,
            array( 'post_content' => $new_content ),
            array( 'ID' => $post_id ),
            array( '%s' ),
            array( '%d' )
        );

        if ( $result !== false ) {
            // Clear the object cache for this post so WP sees the fresh content
            clean_post_cache( $post_id );
        }

        return $result !== false;
    }

    /* ══════════════════════════════════════════════════════════════
     *  WPML HELPERS
     * ══════════════════════════════════════════════════════════════ */

    /* ══════════════════════════════════════════════════════════════
     *  SCRIPT MISMATCH DETECTOR
     *  Catches e.g. English posts wrongly tagged as Arabic in WPML.
     * ══════════════════════════════════════════════════════════════ */

    private static function detect_script_mismatch( $post, $lang ) {
        $text = wp_strip_all_tags( $post->post_title . ' ' . substr( $post->post_content, 0, 2000 ) );
        $text = preg_replace( '/[\s\d\p{P}]+/u', '', $text ); // strip whitespace, numbers, punctuation
        $len  = mb_strlen( $text, 'UTF-8' );
        if ( $len < 20 ) return false;

        // Count scripts
        $latin     = preg_match_all( '/[\x{0041}-\x{024F}]/u', $text );
        $arabic    = preg_match_all( '/[\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $text );
        $hebrew    = preg_match_all( '/[\x{0590}-\x{05FF}\x{FB1D}-\x{FB4F}]/u', $text );
        $cyrillic  = preg_match_all( '/[\x{0400}-\x{04FF}]/u', $text );
        $greek     = preg_match_all( '/[\x{0370}-\x{03FF}]/u', $text );
        $cjk       = preg_match_all( '/[\x{4E00}-\x{9FFF}\x{3400}-\x{4DBF}\x{F900}-\x{FAFF}]/u', $text );
        $hangul    = preg_match_all( '/[\x{AC00}-\x{D7AF}\x{1100}-\x{11FF}\x{3130}-\x{318F}]/u', $text );
        $kana      = preg_match_all( '/[\x{3040}-\x{309F}\x{30A0}-\x{30FF}\x{31F0}-\x{31FF}]/u', $text );
        $hindi     = preg_match_all( '/[\x{0900}-\x{097F}]/u', $text );

        $latin_pct    = $latin / $len;
        $arabic_pct   = $arabic / $len;
        $hebrew_pct   = $hebrew / $len;
        $cyrillic_pct = $cyrillic / $len;
        $greek_pct    = $greek / $len;
        $cjk_pct      = $cjk / $len;
        $hangul_pct   = $hangul / $len;
        $kana_pct     = $kana / $len;
        $hindi_pct    = $hindi / $len;
        $japanese_pct = ( $cjk + $kana ) / $len; // Japanese uses CJK + Hiragana/Katakana

        // Map every language to expected script and min threshold
        // 'script' = what char class should dominate, 'min' = minimum % of that script
        $lang_script_map = array(
            // Latin-script languages (should be >50% Latin)
            'en'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'fr'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'de'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'es'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'it'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'nl'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'pt-br'   => array( 'script' => 'latin',    'min' => 0.50 ),
            'pt-pt'   => array( 'script' => 'latin',    'min' => 0.50 ),
            'da'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'no'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'nb'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'sv'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'fi'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'pl'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'cs'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'ro'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'hu'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'tr'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'az'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'et'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'lv'      => array( 'script' => 'latin',    'min' => 0.50 ),
            'sl'      => array( 'script' => 'latin',    'min' => 0.50 ),
            // Non-Latin script languages
            'ar'      => array( 'script' => 'arabic',   'min' => 0.30 ),
            'he'      => array( 'script' => 'hebrew',   'min' => 0.30 ),
            'ru'      => array( 'script' => 'cyrillic', 'min' => 0.30 ),
            'uk'      => array( 'script' => 'cyrillic', 'min' => 0.30 ),
            'el'      => array( 'script' => 'greek',    'min' => 0.30 ),
            'hi'      => array( 'script' => 'hindi',    'min' => 0.30 ),
            'zh-hans' => array( 'script' => 'cjk',      'min' => 0.20 ),
            'zh-hant' => array( 'script' => 'cjk',      'min' => 0.20 ),
            'ja'      => array( 'script' => 'japanese', 'min' => 0.20 ),
            'ko'      => array( 'script' => 'hangul',   'min' => 0.20 ),
        );

        if ( ! isset( $lang_script_map[ $lang ] ) ) return false; // unknown language, skip check

        $expected = $lang_script_map[ $lang ];
        $script   = $expected['script'];
        $min_pct  = $expected['min'];

        // Get actual percentage for expected script
        $actual_pct = 0;
        switch ( $script ) {
            case 'latin':    $actual_pct = $latin_pct;    break;
            case 'arabic':   $actual_pct = $arabic_pct;   break;
            case 'hebrew':   $actual_pct = $hebrew_pct;   break;
            case 'cyrillic': $actual_pct = $cyrillic_pct; break;
            case 'greek':    $actual_pct = $greek_pct;    break;
            case 'cjk':      $actual_pct = $cjk_pct;      break;
            case 'hangul':   $actual_pct = $hangul_pct;   break;
            case 'japanese': $actual_pct = $japanese_pct; break;
            case 'hindi':    $actual_pct = $hindi_pct;    break;
        }

        // MISMATCH: expected script is below minimum threshold
        if ( $actual_pct < $min_pct ) {
            // Identify what script the content actually is
            $actual_scripts = array(
                'Latin'    => $latin_pct,
                'Arabic'   => $arabic_pct,
                'Hebrew'   => $hebrew_pct,
                'Cyrillic' => $cyrillic_pct,
                'Greek'    => $greek_pct,
                'CJK'      => $cjk_pct,
                'Hangul'   => $hangul_pct,
                'Japanese' => $japanese_pct,
                'Hindi'    => $hindi_pct,
            );
            arsort( $actual_scripts );
            $dominant      = key( $actual_scripts );
            $dominant_pct  = round( current( $actual_scripts ) * 100 );
            $expected_pct  = round( $actual_pct * 100 );

            return "WPML says '{$lang}' (expects {$script}) but content is {$dominant_pct}% {$dominant}, only {$expected_pct}% {$script}";
        }

        return false; // no mismatch
    }

    private static function get_post_language( $post_id ) {
        global $wpdb;

        $icl_table = $wpdb->prefix . 'icl_translations';
        $table_exists = $wpdb->get_var( "SHOW TABLES LIKE '{$icl_table}'" ) === $icl_table;

        if ( ! $table_exists ) return null;

        $post_type = get_post_type( $post_id );
        $element_type = 'post_' . $post_type;

        $lang = $wpdb->get_var( $wpdb->prepare(
            "SELECT language_code FROM {$icl_table}
             WHERE element_id = %d AND element_type = %s",
            $post_id, $element_type
        ) );

        return $lang ?: null;
    }

    /* ══════════════════════════════════════════════════════════════
     *  INJECTION LOG
     * ══════════════════════════════════════════════════════════════ */

    private static function log_injection( $orphan_id, $orphan_url, $lang, $donors ) {
        $log = get_option( self::LOG_KEY, array() );

        array_unshift( $log, array(
            'orphan_id'  => $orphan_id,
            'orphan_url' => $orphan_url,
            'language'   => $lang,
            'donors'     => $donors,
            'date'       => current_time( 'mysql' ),
        ) );

        // Keep all entries — needed for date-based undo
        update_option( self::LOG_KEY, $log, false );
    }

    public static function get_log() {
        return get_option( self::LOG_KEY, array() );
    }

    /**
     * Rollback all injections made within the last N hours.
     */
    public static function rollback_by_date( $hours = 24, $limit = 50 ) {
        global $wpdb;
        $cutoff = date( 'Y-m-d H:i:s', time() - ( $hours * 3600 ) );

        // Find ALL posts with injection meta
        $rows = $wpdb->get_results(
            "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_dba_anti_orphan_injected'"
        );

        $rolled  = 0;
        $failed  = 0;
        $skipped = 0;
        $in_window = 0;
        $processed = 0;

        foreach ( $rows as $row ) {
            $meta = maybe_unserialize( $row->meta_value );
            if ( empty( $meta['date'] ) ) continue;

            if ( $meta['date'] >= $cutoff ) {
                $in_window++;
                if ( $processed < $limit ) {
                    try {
                        $r = self::rollback_orphan( intval( $row->post_id ) );
                        if ( $r['success'] ) {
                            $rolled += $r['rolled_back_count'];
                        } else {
                            $failed++;
                        }
                    } catch ( \Throwable $e ) {
                        $failed++;
                    }
                    $processed++;
                }
            } else {
                $skipped++;
            }
        }

        $remaining = max( 0, $in_window - $processed );

        // Clean log only when fully done
        if ( $remaining === 0 ) {
            $log  = self::get_log();
            $kept = array();
            foreach ( $log as $entry ) {
                if ( ! empty( $entry['date'] ) && $entry['date'] >= $cutoff ) continue;
                $kept[] = $entry;
            }
            update_option( self::LOG_KEY, $kept, false );
        }

        return array(
            'rolled_back' => $rolled,
            'failed'      => $failed,
            'skipped'     => $skipped,
            'remaining'   => $remaining,
            'done'        => ( $remaining === 0 ),
            'cutoff'      => $cutoff,
            'hours'       => $hours,
            'total_found' => $in_window,
        );
    }
}
