<?php
/**
 * Post Translation class - manages post/page translations
 * 
 * @package LangForge
 */

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

// IDE hints for autoloaded classes
if (false) {
    class LF_Core {}
    class LF_ACF_Integration {}
}

class LF_Post_Translation {

    private static $instance = null;
    // Reentrancy guard for the permalink-to-current-language remap: set while we
    // call get_permalink on a sibling translation, so that nested call falls
    // through to the normal own-language permalink logic instead of remapping
    // again (which could recurse on inconsistent data).
    private $localizing_permalink = false;

    public static function instance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    
    private function __construct() {
        $this->init_hooks();
    }
    
    private function init_hooks() {
        // Add language meta box to posts
        add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
        add_action('save_post', [$this, 'save_post_language'], 10, 2);

        // Auto-assign default language to posts missing _lf_language
        // Catches Elementor saves, REST API creates, and any other non-metabox saves
        add_action('save_post', [$this, 'ensure_post_language'], 99, 2);
        add_action('wp_insert_post', [$this, 'ensure_post_language'], 99, 2);

        // Gutenberg sidebar
        add_action('enqueue_block_editor_assets', [$this, 'enqueue_gutenberg_sidebar']);
        
        // Register language columns, filters and views for translatable post types only 
        add_action('current_screen', [$this, 'register_post_list_hooks']);
        
        // Permalink filters for ?lang= parameter format
        add_filter('post_link', [$this, 'filter_permalink'], 10, 2);
        add_filter('page_link', [$this, 'filter_permalink'], 10, 2);
        add_filter('post_type_link', [$this, 'filter_permalink'], 10, 2);
        
        // Allow same slugs for different language posts (parameter format)
        add_filter('wp_unique_post_slug', [$this, 'allow_same_slug'], 10, 6);
        
        // AJAX handlers
        add_action('wp_ajax_lf_create_translation', [$this, 'ajax_create_translation']);
        add_action('wp_ajax_lf_link_translation', [$this, 'ajax_link_translation']);
        add_action('wp_ajax_lf_unlink_translation', [$this, 'ajax_unlink_translation']);
        add_action('wp_ajax_lf_ai_translate_post', [$this, 'ajax_ai_translate_post']);
        
        // Clean up translation records when post is deleted
        add_action('before_delete_post', [$this, 'delete_translation_record']);
    }
    
    /**
 * Delete translation record when post is permanently deleted
 */
    public function delete_translation_record($post_id) {
        global $wpdb;
        $table = $wpdb->prefix . 'lf_translations';

        $wpdb->delete($table, ['element_id' => $post_id]); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.

        // sibling posts' cached translation arrays listed this
        // deleted post — flush them so stale IDs don't leak into the next
        // get_translations call.
        $this->invalidate_all_cache();
    }

    /**
 * Enqueue Gutenberg sidebar script
 */
    public function enqueue_gutenberg_sidebar() {
        $languages = LF_Core::instance()->get_active_languages();

        wp_enqueue_script(
            'avl-gutenberg-sidebar',
            LF_PLUGIN_URL . 'assets/js/gutenberg-sidebar.js',
            ['wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n', 'wp-api-fetch'],
            LF_VERSION,
            true
        );

        wp_localize_script('avl-gutenberg-sidebar', 'lfGutenberg', [
            'languages' => $languages,
            'defaultLanguage' => LF_Core::instance()->get_default_language(),
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce('lf_admin_nonce'),
        ]);
    }

    /**
 * Filter permalink to add ?lang= parameter for non-default language posts
 */
    /**
 * Frontend: resolve a post/page link to the version in the language the
 * visitor is currently viewing, so header / menu navigation never silently
 * falls back to the default language. Returns the rewritten URL, or null to
 * fall through to the normal own-language permalink logic below.
 *
 * Scope is deliberately narrow:
 * - real front-end page views only — never admin / REST / AJAX / cron, and
 * never the bulk per-post enumerations that legitimately need each post's
 * own URL: feeds and sitemaps (WordPress core's `sitemap` query var and
 * SEO Forge's `seoforge_sitemap`),
 * - never while LangForge is itself building a URL (the switcher), so its
 * per-language links keep pointing at their own language.
 * Applies in every language, including the default one: a link to a page in
 * another language (e.g. a theme that resolves "About" by a fixed slug that
 * happens to belong to a non-default-language page) is remapped to the
 * sibling in the language being viewed. When no *published* translation
 * exists there, the original link is kept but the language is preserved so
 * the visitor never silently switches language.
 */
    private function localize_permalink_to_current($permalink, $post_id) {
        if ($this->localizing_permalink) {
            return null;
        }
        if (is_admin() || wp_doing_ajax()
            || (defined('REST_REQUEST') && REST_REQUEST)
            || (defined('DOING_CRON') && DOING_CRON)
            || !did_action('wp') || is_feed()
            || get_query_var('sitemap') || get_query_var('seoforge_sitemap')) {
            return null;
        }
        $core = LF_Core::instance();
        if ($core->is_building_url()) {
            return null;
        }
        $current = $core->get_current_language();
        if (!$current) {
            return null;
        }
        $post_lang = get_post_meta($post_id, '_lf_language', true);
        if (!$post_lang || $post_lang === $current) {
            return null; // untracked, or already the language being viewed
        }

        $translation = $this->get_translation($post_id, $current);
        $tid = is_object($translation) ? (int) $translation->ID : (int) $translation;

        $this->localizing_permalink = true;
        try {
            if ($tid && $tid !== (int) $post_id && get_post_status($tid) === 'publish') {
                return get_permalink($tid);
            }
            // No usable translation — keep this page, stay in the visitor's language.
            return $core->localize_url($permalink, $current);
        } finally {
            $this->localizing_permalink = false;
        }
    }

    public function filter_permalink($permalink, $post) {
        $url_format = get_option('lf_url_format', 'directory');

        $post_id = is_object($post) ? $post->ID : $post;

        // Follow the visitor's current language first (header/menu links must not
        // drop back to the default language). Falls through when not applicable.
        $localized = $this->localize_permalink_to_current($permalink, $post_id);
        if ($localized !== null) {
            return $localized;
        }

        $post_lang = get_post_meta($post_id, '_lf_language', true);

        if (!$post_lang) {
            return $permalink;
        }

        $default_lang = LF_Core::instance()->get_default_language();
        $hide_default = get_option('lf_hide_default_language', false);

        // Don't modify URL for default language if hide option is enabled
        if ($hide_default && $post_lang === $default_lang) {
            return $permalink;
        }

        // Check if this post is a translation of the Front Page (fix)
        $front_page_id = (int) get_option('page_on_front');
        $is_front_page_translation = false;
        if ($front_page_id && get_option('show_on_front') === 'page') {
            $translations = $this->get_translations($front_page_id);
            if (isset($translations[$post_lang]) && (int) $translations[$post_lang] === $post_id) {
                $is_front_page_translation = true;
            }
        }

        if ($url_format === 'parameter') {
            $hide_default = get_option('lf_hide_default_lang', true);
            $default_lang = LF_Core::instance()->get_default_language();
            if ($hide_default && $post_lang === $default_lang) {
                return $is_front_page_translation ? home_url('/') : $permalink;
            }
            if ($is_front_page_translation) {
                return add_query_arg('lang', $post_lang, home_url('/'));
            }
            return add_query_arg('lang', $post_lang, $permalink);
        }

        if ($url_format === 'directory') {
            $home_url = home_url('/');

            // Front Page translation: just language prefix, no slug
            if ($is_front_page_translation) {
                return $home_url . $post_lang . '/';
            }

            if (strpos($permalink, $home_url) === 0) {
                $path = substr($permalink, strlen($home_url));
                // Check if already has language prefix
                $path_parts = explode('/', trim($path, '/'));
                if (!empty($path_parts[0]) && isset(LF_Core::instance()->get_active_languages()[$path_parts[0]])) {
                    return $permalink;
                }
                return $home_url . $post_lang . '/' . $path;
            }
        }

        return $permalink;
    }
    
    /**
 * Allow same slugs for posts in different languages when using parameter format
 */
    public function allow_same_slug($slug, $post_ID, $post_status, $post_type, $post_parent, $original_slug) {
        // Allow same slugs for posts in different languages
        // Directory format: language prefix in URL prevents conflicts (/en/about/ vs /ru/about/)
        // Parameter format: ?lang= parameter differentiates posts

        // If slug was modified (e.g., -2 added), check if we can use original
        if ($slug !== $original_slug) {
            global $wpdb;
            
            // Find existing posts with the original slug
            $existing = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time/admin read
                "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s AND ID != %d AND post_status != 'trash'",
                $original_slug, $post_type, $post_ID
            ));
            
            if ($existing) {
                // Check if all existing posts are in different languages
                $new_post_lang = get_post_meta($post_ID, '_lf_language', true);
                if (!$new_post_lang) {
                    // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by WP core post-edit screen; slug uniqueness filter only.
                    $new_post_lang = isset($_POST['lf_language']) ? sanitize_text_field(wp_unslash($_POST['lf_language'])) : '';
                }
                
                $can_use_original = true;
                foreach ($existing as $ex_post) {
                    $ex_lang = get_post_meta($ex_post->ID, '_lf_language', true);
                    if ($ex_lang === $new_post_lang || (empty($ex_lang) && empty($new_post_lang))) {
                        $can_use_original = false;
                        break;
                    }
                }
                
                if ($can_use_original) {
                    return $original_slug;
                }
            }
        }
        
        return $slug;
    }
    
    /**
 * Add language views filter for all public post types
 */
    /**
 * Register language columns, filters, and views only for translatable post types.
 * Runs on current_screen to know which post type we're on. 
 */
    public function register_post_list_hooks() {
        $screen = get_current_screen();
        if (!$screen) return;

        $translatable = LF_Core::get_translatable_post_types();

        // Post list screen (edit.php)
        if ($screen->base === 'edit' && in_array($screen->post_type, $translatable, true)) {
            // Language column
            if ($screen->post_type === 'page') {
                add_filter('manage_pages_columns', [$this, 'add_language_column']);
                add_action('manage_pages_custom_column', [$this, 'render_language_column'], 10, 2);
            } else {
                add_filter('manage_posts_columns', [$this, 'add_language_column']);
                add_action('manage_posts_custom_column', [$this, 'render_language_column'], 10, 2);
            }

            // Language filter dropdown + query filter
            add_action('restrict_manage_posts', [$this, 'add_language_filter']);
            add_filter('parse_query', [$this, 'filter_posts_by_language_admin']);

            // Language views (All | Russian | English | ...)
            add_filter('views_edit-' . $screen->post_type, [$this, 'add_language_views']);
        }
    }
    
    /**
 * Add language filter links to post list views (like WPML)
 */
    public function add_language_views($views) {
        global $wpdb;
        
        $screen = get_current_screen();
        $post_type = $screen ? $screen->post_type : 'post';
        $languages = LF_Core::instance()->get_active_languages();
        
        if (empty($languages)) {
            return $views;
        }
        
        // Get current language filter — default to all languages
        if (isset($_GET['lf_lang']) && $_GET['lf_lang'] !== '') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin list filter, no data mutation.
            $current_lang = sanitize_text_field(wp_unslash($_GET['lf_lang'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin list filter, no data mutation.
        } else {
            $current_lang = '';
        }
        
        // Count posts per language — single query for all languages
        $lang_counts = [];
        $total = 0;

        $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time/admin read
            "SELECT pm.meta_value AS lang, COUNT(DISTINCT p.ID) AS cnt
            FROM {$wpdb->posts} p
            INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
            WHERE p.post_type = %s
            AND p.post_status NOT IN ('trash','auto-draft')
            AND pm.meta_key = '_lf_language'
            AND pm.meta_value != ''
            GROUP BY pm.meta_value",
            $post_type
        ), ARRAY_A);

        foreach ($rows as $row) {
            $lang_counts[$row['lang']] = intval($row['cnt']);
            $total += intval($row['cnt']);
        }
        // Fill missing languages with 0
        foreach ($languages as $code => $lang) {
            if (!isset($lang_counts[$code])) $lang_counts[$code] = 0;
        }
        
        // Also count posts without language set
        $no_lang_count = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time/admin read
            "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
            LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_lf_language'
            WHERE p.post_type = %s 
            AND p.post_status != 'trash'
            AND p.post_status != 'auto-draft'
            AND (pm.meta_value IS NULL OR pm.meta_value = '')",
            $post_type
        ));
        
        // Build language links HTML
        $lang_links = [];
        
        foreach ($languages as $code => $lang) {
            $url   = add_query_arg(['post_type' => $post_type, 'lf_lang' => $code], admin_url('edit.php'));
            $class = ($current_lang === $code) ? 'current' : '';
            $lang_links[] = sprintf(
                '<a href="%s" class="%s">%s <span class="count">(%d)</span></a>',
                esc_url($url),
                $class,
                esc_html($lang['native_name']),
                $lang_counts[$code]
            );
        }
        
        // Add "All languages" link — shown as current when no filter is active
        $all_url = add_query_arg(['post_type' => $post_type], admin_url('edit.php'));
        $all_class = empty($current_lang) ? 'current' : '';
        $all_count = $total + $no_lang_count;
        
        array_unshift($lang_links, sprintf(
            '<a href="%s" class="%s">%s <span class="count">(%d)</span></a>',
            esc_url($all_url),
            $all_class,
            __('All languages', 'langforge'),
            $all_count
        ));
        
        // Add language links as a separate line
        if (!empty($lang_links)) {
            $views['lf_languages'] = '<span class="lf-language-filters">' . implode(' | ', $lang_links) . '</span>';
        }
        
        return $views;
    }
    
    public function add_meta_boxes() {
        // Only show language metabox for configured translatable types 
        $post_types = LF_Core::get_translatable_post_types();

        // LearnDash hides generic sidebar postboxes on non-Page
        // tabs via CSS. LangForge still registers the canonical side
        // metabox only; admin.css re-opens this specific box across tabs.
        foreach ($post_types as $post_type) {
            add_meta_box(
                'lf_language_box',
                __('Language', 'langforge'),
                [$this, 'render_meta_box'],
                $post_type,
                'side',
                'high'
            );
        }
    }

    private function get_metabox_language_label($code, $lang) {
        $normalized = strtolower(str_replace('-', '_', (string) $code));
        $labels = [
            'en' => 'En',
            'ru' => 'Ru',
            'uz' => "O'zb",
        ];

        if (isset($labels[$normalized])) {
            return $labels[$normalized];
        }

        $parts = array_values(array_filter(explode('_', $normalized)));
        if (count($parts) > 1) {
            return ucfirst($parts[0]) . '-' . strtoupper($parts[1]);
        }

        if (!empty($parts[0])) {
            return ucfirst($parts[0]);
        }

        return !empty($lang['native_name']) ? $lang['native_name'] : (string) $code;
    }
    
    public function render_meta_box($post) {
        wp_nonce_field('lf_save_language', 'lf_language_nonce');
        
        $current_lang = get_post_meta($post->ID, '_lf_language', true);
        if (!$current_lang) {
            $current_lang = LF_Core::instance()->get_default_language();
        }
        
        $languages = LF_Core::instance()->get_active_languages();
        $translations = $this->get_translations($post->ID);
        ?>
        <?php
        // AI translation is a Pro feature — class lives in langforge-pro.
        // Gate on the LICENSE, not just a cached API key: after a downgrade the
        // Pro add-on can still be active with a key in lf_license_data, and
        // is_configured alone would keep the button visible (and, before the
        // server fix in ajax_ai_translate_post, actually runnable). Require
        // can('ai_translation') so the button hides the moment the license lapses.
        $ai_available = class_exists('LF_AI_Client') && LF_AI_Client::instance()->is_configured()
            && class_exists('LF_License') && LF_License::can('ai_translation');
        $qa_available = class_exists('LF_Translation_QA')
            && class_exists('LF_License')
            && LF_License::can('translation_qa')
            && $ai_available;
        $default_lang = LF_Core::instance()->get_default_language();
        $is_original = ($current_lang === $default_lang);
        ?>
        <div class="lf-language-box">
            <p>
                <label for="lf_language"><strong><?php esc_html_e('Post Language:', 'langforge'); ?></strong></label>
                <?php if (class_exists('Forge_Docs_Link')) Forge_Docs_Link::render('langforge', 'translating-a-post-or-page', __('Post language', 'langforge'), __('Set which language this post is written in; switch via its translation link.', 'langforge')); ?>
                <select name="lf_language" id="lf_language" class="widefat">
                    <?php foreach ($languages as $code => $lang): ?>
                        <option value="<?php echo esc_attr($code); ?>" <?php selected($current_lang, $code); ?>>
                            <?php echo esc_html($lang['native_name']); ?>
                        </option>
                    <?php endforeach; ?>
                </select>
            </p>

            <?php
            // Find the original (source) post for this translation
            $source_post_id = null;
            $source_lang = $default_lang;
            if (!$is_original && !empty($translations)) {
                // Current post is a translation — find the original
                foreach ($translations as $t_lang => $t_id) {
                    if ($t_lang === $default_lang) {
                        $source_post_id = $t_id;
                        break;
                    }
                }
            }
            ?>

            <div class="lf-translations">
                <strong><?php esc_html_e('Translations:', 'langforge'); ?></strong>
                <?php if (class_exists('Forge_Docs_Link')) Forge_Docs_Link::render('langforge', 'linking-an-already-written-translation', __('Translations', 'langforge'), __('Create, link, or open translations of this post in other active languages.', 'langforge')); ?>

                <?php if (!$is_original && $ai_available && $source_post_id): ?>
                <!-- AI Translate button for translation posts -->
                <div style="margin:8px 0;">
                    <button type="button" class="lf-ai-translate-btn" style="width:100%;padding:8px;border-radius:6px;justify-content:center;gap:6px;font-size:14px;font-weight:600;"
                        data-source="<?php echo esc_attr($source_post_id); ?>"
                        data-target="<?php echo esc_attr($post->ID); ?>"
                        data-source-lang="<?php echo esc_attr($source_lang); ?>"
                        data-target-lang="<?php echo esc_attr($current_lang); ?>">
                        <span class="dashicons dashicons-translation" style="font-size:14px;width:14px;height:14px;"></span>
                        <?php /* translators: %s: the source language code (uppercase) the content is translated from. */ printf(esc_html__('AI Translate from %s', 'langforge'), esc_html(strtoupper($source_lang))); ?>
                        <?php echo class_exists('Forge_Credits_Widget') ? wp_kses_post(Forge_Credits_Widget::render_badge_html('translate_post', 'lf')) : ''; ?>
                    </button>
                </div>
                <?php endif; ?>

                <?php if (!$is_original && $qa_available && $source_post_id): ?>
                <!-- QA Check button for translation posts -->
                <div class="lf-qa-section" style="margin:8px 0;">
                    <button type="button" class="lf-qa-check-btn" style="width:100%;padding:8px;border-radius:6px;justify-content:center;gap:6px;font-size:14px;font-weight:600;"
                        data-original="<?php echo esc_attr($source_post_id); ?>"
                        data-translation="<?php echo esc_attr($post->ID); ?>"
                        data-source-lang="<?php echo esc_attr($source_lang); ?>"
                        data-target-lang="<?php echo esc_attr($current_lang); ?>">
                        <span class="dashicons dashicons-yes-alt" style="font-size:14px;width:14px;height:14px;"></span>
                        <?php esc_html_e('Check Quality', 'langforge'); ?>
                        <?php echo class_exists('Forge_Credits_Widget') ? wp_kses_post(Forge_Credits_Widget::render_badge_html('qa_check', 'lf')) : ''; ?>
                    </button>
                    <div class="lf-qa-results" id="lf-qa-results" style="display:none;"></div>
                </div>
                <?php endif; ?>

                <?php
                // Visual Diff panel — show when source was updated after translation
                $needs_update = get_post_meta($post->ID, '_lf_needs_update', true);
                if (!$is_original && $source_post_id && $needs_update && class_exists('LF_License') && LF_License::can('content_diff')):
                    $updated_ago = human_time_diff(strtotime($needs_update), current_time('timestamp'));
                ?>
                <div class="lf-diff-notice" id="lf-diff-notice">
                    <div class="lf-diff-notice-header">
                        <span class="dashicons dashicons-warning" style="color:#d97706;font-size:16px;width:16px;height:16px;"></span>
                        <strong><?php echo esc_html__('Content Changed', 'langforge'); ?></strong>
                        <span class="lf-diff-ago">(<?php echo esc_html($updated_ago); ?> <?php esc_html_e('ago', 'langforge'); ?>)</span>
                    </div>
                    <div class="lf-diff-actions" style="margin:6px 0;display:flex;gap:6px;flex-wrap:wrap;">
                        <button type="button" class="button button-small" id="lf-toggle-diff" data-post-id="<?php echo esc_attr($post->ID); ?>">
                            <span class="dashicons dashicons-visibility" style="font-size:14px;width:14px;height:14px;vertical-align:text-top;"></span>
                            <?php echo esc_html__('Show Changes', 'langforge'); ?>
                        </button>
                        <button type="button" class="button button-small button-primary" id="lf-mark-current" data-post-id="<?php echo esc_attr($post->ID); ?>">
                            <span class="dashicons dashicons-yes" style="font-size:14px;width:14px;height:14px;vertical-align:text-top;"></span>
                            <?php echo esc_html__('Mark as Current', 'langforge'); ?>
                        </button>
                    </div>
                    <div class="lf-diff-container" id="lf-diff-container" style="display:none;"></div>
                </div>
                <script>
                jQuery(function($){
                    // Toggle diff view
                    $('#lf-toggle-diff').on('click', function(){
                        var $c = $('#lf-diff-container');
                        if ($c.is(':visible')) {
                            $c.slideUp(200);
                            return;
                        }
                        if ($c.data('loaded')) {
                            $c.slideDown(200);
                            return;
                        }
                        $c.html('<p style="color:#64748b;font-size:14px;">Loading...</p>').slideDown(200);
                        $.post(ajaxurl, {
                            action: 'lf_get_content_diff',
                            nonce: '<?php echo esc_html(wp_create_nonce("lf_admin_nonce")); ?>',
                            post_id: $(this).data('post-id')
                        }, function(r){
                            if (r.success) {
                                $c.html(r.data.diff_html).data('loaded', true);
                            }
                        });
                    });
                    // Mark as current
                    $('#lf-mark-current').on('click', function(){
                        var $btn = $(this);
                        $btn.prop('disabled', true).text('...');
                        $.post(ajaxurl, {
                            action: 'lf_mark_translation_current',
                            nonce: '<?php echo esc_html(wp_create_nonce("lf_admin_nonce")); ?>',
                            post_id: $btn.data('post-id')
                        }, function(r){
                            if (r.success) {
                                $('#lf-diff-notice').slideUp(200);
                            } else {
                                $btn.prop('disabled', false).text('<?php echo esc_js(__('Mark as Current', 'langforge')); ?>');
                            }
                        });
                    });
                });
                </script>
                <?php endif; ?>

                <ul class="lf-translation-list">
                    <?php foreach ($languages as $code => $lang): ?>
                        <?php if ($code === $current_lang) continue; ?>
                        <?php $has_translation = isset($translations[$code]); ?>
                        <?php $full_lang_name = !empty($lang['native_name']) ? $lang['native_name'] : (!empty($lang['name']) ? $lang['name'] : $code); ?>
                        <li class="lf-translation-item <?php echo $has_translation ? 'is-linked' : 'is-empty'; ?>" data-lang="<?php echo esc_attr($code); ?>">
                            <span class="lf-lang-name" title="<?php echo esc_attr($full_lang_name); ?>" aria-label="<?php echo esc_attr($full_lang_name); ?>"><?php echo esc_html($this->get_metabox_language_label($code, $lang)); ?></span>
                            <?php if ($has_translation): ?>
                                <a href="<?php echo esc_url(get_edit_post_link($translations[$code])); ?>" class="lf-edit-translation" title="<?php echo esc_attr(get_the_title($translations[$code])); ?>">
                                    <?php echo esc_html(get_the_title($translations[$code])); ?>
                                </a>
                                <span class="lf-translation-actions">
                                    <button type="button" class="lf-unlink-btn" data-post-id="<?php echo esc_attr($post->ID); ?>" data-lang="<?php echo esc_attr($code); ?>" title="<?php esc_attr_e('Unlink', 'langforge'); ?>">×</button>
                                </span>
                            <?php else: ?>
                                <span class="lf-missing"><?php esc_html_e('—', 'langforge'); ?></span>
                                <span class="lf-translation-actions">
                                    <?php if ($is_original): ?>
                                    <button type="button" class="button button-small lf-create-translation" data-post-id="<?php echo esc_attr($post->ID); ?>" data-lang="<?php echo esc_attr($code); ?>">
                                        <?php esc_html_e('+ Create', 'langforge'); ?>
                                    </button>
                                    <button type="button" class="button button-small lf-link-translation" data-post-id="<?php echo esc_attr($post->ID); ?>" data-lang="<?php echo esc_attr($code); ?>">
                                        <?php esc_html_e('Link', 'langforge'); ?>
                                    </button>
                                    <?php endif; ?>
                                </span>
                            <?php endif; ?>
                        </li>
                    <?php endforeach; ?>
                </ul>
            </div>
        </div>

        <style>
        .lf-language-box p{margin-bottom:12px;}
        .lf-language-box select{padding:8px 30px 8px 10px!important;border-radius:6px!important;border:1px solid #e2e8f0!important;-webkit-appearance:none!important;appearance:none!important;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M2 4l4 4 4-4'/%3E%3C/svg%3E")!important;background-repeat:no-repeat!important;background-position:right 8px center!important;background-color:#fff!important;}
        /* Translations row uses 3-column grid — language | title/dash | actions —
 so long linked titles stay on a single line with ellipsis and never
 push buttons to wrap, and every row keeps the same visual rhythm
 regardless of which state it's in (linked / empty / original vs
 translation view). Must mirror the .lf-translation-item rules in
 admin.css because this inline block wins on specificity; keep them
 in sync. */
        .lf-translation-list{margin:10px 0 0;padding:0;list-style:none;border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;}
        .lf-translation-item{margin:0;padding:8px 8px 8px 10px;background:#f8fafc;border-left:3px solid #0EC49E;border-bottom:1px solid #e2e8f0;display:grid;grid-template-columns:minmax(34px,max-content) minmax(0,1fr) auto;gap:6px;align-items:center;min-height:44px;line-height:1.4;box-sizing:border-box;transition:background .15s ease;}
        .lf-translation-item:last-child{border-bottom:none;}
        .lf-translation-item:hover{background:#F0FDF9;}
        .lf-lang-name{min-width:34px;max-width:46px;font-weight:600;font-size:13px;line-height:1.4;color:#1e293b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
        .lf-edit-translation{font-size:14px;line-height:1.4;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#0EC49E;text-decoration:none;}
        .lf-edit-translation:hover{text-decoration:underline;color:#0CB38E;}
        .lf-missing{color:#94a3b8;font-size:14px;line-height:1.4;}
        .lf-translation-actions{display:inline-flex;gap:4px;align-items:center;flex-shrink:0;justify-self:end;min-width:0;}
        .lf-unlink-btn{background:#f1f5f9;color:#94a3b8;border:none;border-radius:4px;cursor:pointer;padding:0 6px;min-height:22px;line-height:20px;font-size:14px;display:inline-flex;align-items:center;justify-content:center;vertical-align:middle;box-sizing:border-box;transition:all 0.15s;}
        .lf-unlink-btn:hover{background:#fee2e2;color:#dc2626;}
        /* pin button height via min-height + line-height so bumped admin font-size doesn't bloat these CTAs. Mirrors admin.css rule — both must agree or the later-loaded inline declaration would win. */
        .lf-create-translation,.lf-link-translation{font-size:12px!important;padding:0 7px!important;min-height:28px!important;line-height:26px!important;border-radius:5px!important;border:1px solid #e2e8f0!important;background:#fff!important;color:#0EC49E!important;font-weight:500!important;cursor:pointer;display:inline-flex!important;align-items:center;vertical-align:middle;box-sizing:border-box;white-space:nowrap;transition:all 0.15s!important;}
        .lf-create-translation:hover,.lf-link-translation:hover{background:#F0FDF9!important;border-color:#99F6E0!important;}
        .lf-ai-translate-btn{background:linear-gradient(135deg,#0EC49E,#0A9B7C);color:#fff;border:none;border-radius:6px;cursor:pointer;padding:4px 8px;transition:all 0.15s;display:inline-flex;align-items:center;font-size:14px;}
        .lf-ai-translate-btn:hover{opacity:0.9;}
        .lf-ai-translate-btn .dashicons{font-size:14px;width:14px;height:14px;}
        .lf-ai-translate-btn.loading{opacity:0.5;pointer-events:none;}
        .lf-ai-translate-btn.loading .dashicons{animation:lf-metabox-spin 1s linear infinite;}
        .lf-credits-badge{display:inline-flex;align-items:center;gap:3px;background:rgba(255,255,255,0.25);color:#fff;font-size:14px;font-weight:600;padding:1px 5px;border-radius:3px;vertical-align:middle;margin-left:2px;letter-spacing:0.3px;opacity:0.8;white-space:nowrap;}
        .lf-credits-badge svg{width:11px;height:11px;fill:currentColor;flex-shrink:0;}
        @keyframes lf-metabox-spin{to{transform:rotate(360deg);}}
        /* QA Check styles */
        .lf-qa-check-btn{background:linear-gradient(135deg,#059669,#10b981);color:#fff;border:none;border-radius:6px;cursor:pointer;padding:4px 8px;transition:all 0.15s;display:inline-flex;align-items:center;font-size:14px;}
        .lf-qa-check-btn:hover{opacity:0.9;}
        .lf-qa-check-btn .dashicons{font-size:14px;width:14px;height:14px;}
        .lf-qa-check-btn.loading{opacity:0.5;pointer-events:none;}
        .lf-qa-check-btn.loading .dashicons{animation:lf-metabox-spin 1s linear infinite;}
        .lf-qa-score-circle{width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700;margin:8px auto;color:#fff;}
        .lf-qa-score-high{background:linear-gradient(135deg,#059669,#10b981);}
        .lf-qa-score-medium{background:linear-gradient(135deg,#d97706,#f59e0b);}
        .lf-qa-score-low{background:linear-gradient(135deg,#dc2626,#ef4444);}
        .lf-qa-summary{text-align:center;font-size:14px;color:#64748b;margin:4px 0 8px;}
        .lf-qa-issue{padding:8px;margin:6px 0;border-radius:6px;border:1px solid #e2e8f0;background:#f8fafc;font-size:14px;}
        .lf-qa-issue-header{display:flex;align-items:center;gap:6px;margin-bottom:4px;}
        .lf-qa-severity{display:inline-block;padding:1px 6px;border-radius:3px;font-size:14px;font-weight:600;color:#fff;text-transform:uppercase;}
        .lf-qa-severity-critical{background:#dc2626;}
        .lf-qa-severity-warning{background:#d97706;}
        .lf-qa-severity-info{background:#2563eb;}
        .lf-qa-issue-type{font-size:14px;color:#94a3b8;font-weight:500;}
        .lf-qa-issue-text{color:#334155;line-height:1.4;margin:2px 0;}
        .lf-qa-issue-text strong{color:#1e293b;}
        .lf-qa-fix-btn{background:#0EC49E;color:#fff;border:none;border-radius:4px;cursor:pointer;padding:3px 8px;font-size:14px;font-weight:600;margin-top:4px;transition:all 0.15s;}
        .lf-qa-fix-btn:hover{opacity:0.85;}
        .lf-qa-fix-btn:disabled{opacity:0.5;cursor:not-allowed;}
        </style>
        <script>
        jQuery(function($){
            // Language switcher: navigate to translation post when language changes
            var translations = <?php echo wp_json_encode(array_map('intval', $translations)); ?>;
            var currentLang = <?php echo wp_json_encode($current_lang); ?>;
            var currentPostId = <?php echo intval($post->ID); ?>;

            // Hide languages without translations from dropdown
            $('#lf_language option').each(function(){
                var code = $(this).val();
                if (code === currentLang) return; // keep current
                if (!translations[code]) {
                    $(this).remove();
                }
            });

            $('#lf_language').on('change', function(){
                var newLang = $(this).val();
                if (newLang === currentLang) return;

                if (translations[newLang]) {
                    window.location.href = 'post.php?post=' + translations[newLang] + '&action=edit&lang=' + newLang;
                }
            });
        });
        </script>
        <?php
    }
    
    public function save_post_language($post_id, $post) {
        if (!isset($_POST['lf_language_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['lf_language_nonce'])), 'lf_save_language')) {
            return;
        }
        
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }
        
        if (!current_user_can('edit_post', $post_id)) {
            return;
        }
        
        if (isset($_POST['lf_language'])) {
            $language = sanitize_text_field(wp_unslash($_POST['lf_language']));
            $old_language = get_post_meta($post_id, '_lf_language', true);
            
            update_post_meta($post_id, '_lf_language', $language);
            
            // Update translation group
            $this->update_translation_record($post_id, $post->post_type, $language);
        }
    }
    
    /**
 * Auto-assign default language to any post that's missing _lf_language.
 * Runs at low priority (99) on save_post / wp_insert_post to catch Elementor,
 * REST API, and any other save path that bypasses the metabox nonce.
 */
    public function ensure_post_language($post_id, $post) {
        // Skip revisions, autosaves, nav_menu_items
        if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
            return;
        }
        if ($post->post_type === 'revision' || $post->post_type === 'nav_menu_item') {
            return;
        }
        if ($post->post_status === 'auto-draft' || $post->post_status === 'trash') {
            return;
        }

        // Only act on translatable post types
        $translatable = LF_Core::get_translatable_post_types();
        if (!in_array($post->post_type, $translatable)) {
            return;
        }

        // If language is already set, nothing to do
        $existing = get_post_meta($post_id, '_lf_language', true);
        if (!empty($existing)) {
            return;
        }

        // Assign the default language and register in translations table
        $default_lang = LF_Core::instance()->get_default_language();
        update_post_meta($post_id, '_lf_language', $default_lang);
        $this->update_translation_record($post_id, $post->post_type, $default_lang);
    }

    public function add_language_column($columns) {
        $new_columns = [];
        foreach ($columns as $key => $value) {
            $new_columns[$key] = $value;
            if ($key === 'title') {
                $new_columns['lf_language'] = __('Language', 'langforge');
            }
        }
        return $new_columns;
    }
    
    public function render_language_column($column, $post_id) {
        if ($column !== 'lf_language') {
            return;
        }

        $lang = get_post_meta($post_id, '_lf_language', true);
        if (!$lang) {
            $lang = LF_Core::instance()->get_default_language();
        }

        $languages = LF_Core::instance()->get_active_languages();
        $translations = $this->get_translations($post_id);

        // wrap pills in an inline-flex container so WooCommerce's
        // narrow product-list column doesn't collapse them into a single
        // vertical stack. With wrap + gap they flow horizontally when there
        // is room, and drop into a second line only when the column is
        // genuinely too narrow — and stay vertically aligned either way.
        echo '<span class="lf-lang-cell">';
        echo '<span class="lf-lang-badge">' . esc_html(strtoupper($lang)) . '</span>';

        // Show translation indicators with quick-create links
        foreach ($languages as $code => $info) {
            if ($code === $lang) continue;
            if (isset($translations[$code])) {
                echo '<a href="' . esc_url(get_edit_post_link($translations[$code])) . '" class="lf-col-trans lf-col-trans--exists" title="' . esc_attr($info['native_name']) . ': ' . esc_attr(get_the_title($translations[$code])) . '">' . esc_html(strtoupper($code)) . '</a>';
            } else {
                $nonce = wp_create_nonce('lf_admin_nonce');
                $create_url = add_query_arg([
                    'post_type'   => get_post_type($post_id),
                    'lf_language' => $code,
                    'lf_source'   => $post_id,
                ], admin_url('post-new.php'));
                /* translators: %s: the target language name for which to create a translation. */
                echo '<a href="' . esc_url($create_url) . '" class="lf-col-trans lf-col-trans--create" data-post-id="' . esc_attr($post_id) . '" data-lang="' . esc_attr($code) . '" data-nonce="' . esc_attr($nonce) . '" title="' . esc_attr(sprintf(__('Create %s translation', 'langforge'), $info['native_name'])) . '">+' . esc_html(strtoupper($code)) . '</a>';
            }
        }

        echo '</span>';
    }
    
    public function add_language_filter() {
        global $typenow;

        $languages = LF_Core::instance()->get_active_languages();
        // Default to default language when no filter is explicitly set
        $default_lang = LF_Core::instance()->get_default_language();
        if (isset($_GET['lf_lang'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin list filter, no data mutation.
            $current = sanitize_text_field(wp_unslash($_GET['lf_lang'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin list filter, no data mutation.
        } else {
            $current = $default_lang;
        }
        ?>
        <select name="lf_lang">
            <option value=""><?php esc_html_e('All languages', 'langforge'); ?></option>
            <?php foreach ($languages as $code => $lang): ?>
                <option value="<?php echo esc_attr($code); ?>" <?php selected($current, $code); ?>>
                    <?php echo esc_html($lang['native_name']); ?>
                </option>
            <?php endforeach; ?>
        </select>
        <?php
    }

    public function filter_posts_by_language_admin($query) {
        global $pagenow;

        if (!is_admin() || $pagenow !== 'edit.php') {
            return;
        }

        // Determine which language to filter by:
        // - explicit ?lf_lang=XX → use that language
        // - no param or empty → show all languages (default)
        $lang = '';
        if (isset($_GET['lf_lang']) && $_GET['lf_lang'] !== '') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin list filter, no data mutation.
            $lang = sanitize_text_field(wp_unslash($_GET['lf_lang'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin list filter, no data mutation.
        }

        if (!empty($lang)) {
            $meta_query = $query->get('meta_query') ?: [];
            $meta_query[] = [
                'key' => '_lf_language',
                'value' => $lang,
            ];
            $query->set('meta_query', $meta_query);
        }
    }
    
    // Translation management
    public function get_post_language($post_id) {
        $lang = get_post_meta($post_id, '_lf_language', true);
        return $lang ?: LF_Core::instance()->get_default_language();
    }

    public function is_language_available($language) {
        $language = sanitize_text_field((string) $language);
        return $language !== '' && LF_Core::instance()->get_language_info($language) !== null;
    }

    public function require_language_available($language) {
        if ($this->is_language_available($language)) {
            return null;
        }

        return new WP_Error(
            'language_not_available',
            __('This language is not available on the current Lang Forge plan.', 'langforge'),
            ['status' => 403]
        );
    }
    
    public function set_post_language($post_id, $language) {
        update_post_meta($post_id, '_lf_language', $language);
        $post_type = get_post_type($post_id);
        $this->update_translation_record($post_id, $post_type, $language);
    }
    
    /** @var array Cache for translation group lookups */
    private $group_cache = [];

    /**
 * v2: Thin wrapper — delegates to get_translations which triggers
 * the batched lazy-preload. Eliminates the old point-query pattern
 * (SELECT translation_group WHERE element_id = X) that fired whenever
 * resolve_translated_singular ran on pre_get_posts, before the 'wp'
 * preload hook had a chance to run.
 */
    public function get_translation_group($post_id) {
        $post_id = (int) $post_id;
        if (isset($this->group_cache[$post_id])) {
            return $this->group_cache[$post_id];
        }
        // Triggers lazy bulk preload — also fills $group_cache[$post_id].
        $this->get_translations($post_id);
        return $this->group_cache[$post_id] ?? null;
    }
    
    /**
 * In-memory cache for translations to avoid N+1 queries .
 * @var array
 */
    private $translations_cache = [];
    private $bulk_preloaded = false;

    /**
 * Preload translation groups for a batch of post IDs in a SINGLE query.
 * Eliminates N+1 queries when menu, sidebar, or archive loops call
 * get_translation for each post individually.
 *
 * @since 1.0.0 (fix)
 * @param int $post_ids Array of post IDs to preload.
 */
    public function preload_translations(array $post_ids) {
        if (empty($post_ids)) return;

        // Filter out already-cached IDs
        $post_ids = array_filter(array_map('intval', $post_ids), function($id) {
            return $id > 0 && !isset($this->translations_cache[$id]);
        });
        if (empty($post_ids)) return;

        global $wpdb;
        $table = $wpdb->prefix . 'lf_translations';
        $ids_str = implode(',', $post_ids);

        // Single self-join fetches both the translation groups for the
        // requested post IDs AND every sibling translation in those groups.
        // Replaces the previous two-query approach (WHERE element_id IN → then
        // WHERE translation_group IN) with one round-trip, halving the preload
        // cost for both preload_visible_translations and preload_menu_translations.
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix; IN list is integer IDs cast via intval.
        $all_translations = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; integer IDs cast via intval.
            "SELECT t2.element_id, t2.language_code, t2.translation_group
             FROM $table t2
             INNER JOIN $table t1 ON t1.translation_group = t2.translation_group
             WHERE t1.element_id IN ($ids_str)"
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        if (empty($all_translations)) {
            foreach ($post_ids as $pid) {
                $this->translations_cache[$pid] = [];
                $this->group_cache[$pid] = null;
            }
            return;
        }

        // Build translations per group
        $by_group = [];
        foreach ($all_translations as $row) {
            $by_group[(int)$row->translation_group][$row->language_code] = (int)$row->element_id;
        }

        // Cache translations + group for every post in each group
        foreach ($by_group as $group => $translations) {
            foreach ($translations as $lang => $tid) {
                $this->translations_cache[$tid] = $translations;
                $this->group_cache[$tid] = $group;
            }
        }

        // Mark posts with no translation group as empty (negative-cache)
        foreach ($post_ids as $pid) {
            if (!isset($this->translations_cache[$pid])) {
                $this->translations_cache[$pid] = [];
                $this->group_cache[$pid] = null;
            }
        }
    }

    public function get_translations($post_id) {
        $post_id = (int) $post_id;

        // Return from cache if available
        if (isset($this->translations_cache[$post_id])) {
            return $this->translations_cache[$post_id];
        }

        // v2: on first cache miss, run a batched bulk-preload covering
        // the requested $post_id plus "likely-needed" sibling posts (front
        // page, posts page, main-query posts). This catches callers that fire
        // BEFORE the 'wp' action (e.g. LF_Core::resolve_translated_singular
        // hooked on pre_get_posts) — previously each such call produced two
        // point queries (translation_group WHERE element_id = X, then WHERE
        // translation_group = Y). Now the first miss produces ONE self-join
        // query covering the post + its likely peers, and $bulk_preloaded
        // prevents re-running the "likely-needed" scan on subsequent misses
        // (individual rare misses still fall through to a 1-row preload).
        $ids = [$post_id];
        if (!$this->bulk_preloaded) {
            $this->bulk_preloaded = true;
            global $wp_query;
            if (!empty($wp_query->posts)) {
                foreach ($wp_query->posts as $p) {
                    $ids[] = is_object($p) ? $p->ID : (int) $p;
                }
            }
            // Read raw option values WITHOUT triggering option_page_on_front /
            // option_page_for_posts filters. LF_Core registers
            // translate_front_page_id on those filters, which itself calls
            // get_translation — a plain get_option here would cause a
            // nested lazy-preload → split the batch into extra IN(X) queries.
            // wp_load_alloptions serves the raw cached option array with no
            // per-option filter pass.
            $alloptions = function_exists('wp_load_alloptions') ? wp_load_alloptions() : [];
            $front_page = isset($alloptions['page_on_front']) ? (int) $alloptions['page_on_front'] : 0;
            $posts_page = isset($alloptions['page_for_posts']) ? (int) $alloptions['page_for_posts'] : 0;
            if ($front_page) $ids[] = $front_page;
            if ($posts_page) $ids[] = $posts_page;
        }
        $this->preload_translations(array_unique(array_filter($ids)));

        // preload_translations always populates $translations_cache for
        // every id it processed (real translations or empty negative-cache),
        // so by this point the answer is guaranteed to be in cache.
        if (isset($this->translations_cache[$post_id])) {
            return $this->translations_cache[$post_id];
        }

        // Defensive fallback (should not happen) — keep the legacy 2-query
        // path so an unexpected preload bug never breaks the public API.
        global $wpdb;
        $table = $wpdb->prefix . 'lf_translations';

        $group = isset($this->group_cache[$post_id]) ? $this->group_cache[$post_id] : null;
        if (!$group) {
            $this->translations_cache[$post_id] = [];
            return [];
        }

        // Get all translations in this group regardless of element_type
        $results = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
            "SELECT element_id, language_code FROM $table WHERE translation_group = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, value is %-prepared.
            $group
        ), ARRAY_A);

        $translations = [];

        foreach ($results as $row) {
            $element_id = (int) $row['element_id'];
            // Skip expensive get_post_status on frontend — trust the DB
            // Orphan cleanup runs on admin save, not on every frontend load
            $translations[$row['language_code']] = $element_id;
        }

        // Cache for all posts in this translation group
        foreach ($translations as $lang => $tid) {
            $this->translations_cache[$tid] = $translations;
        }

        return $translations;
    }
    
    public function get_translation($post_id, $language) {
        $translations = $this->get_translations($post_id);
        return $translations[$language] ?? null;
    }
    
    public function get_translation_id($element_id, $element_type = 'post', $language = null, $return_original = false) {
        if (!$language) {
            $language = LF_Core::instance()->get_current_language();
        }
        
        $translations = $this->get_translations($element_id);
        
        if (isset($translations[$language])) {
            return $translations[$language];
        }
        
        return $return_original ? $element_id : null;
    }
    
    private function update_translation_record($post_id, $post_type, $language) {
        global $wpdb;
        $table = $wpdb->prefix . 'lf_translations';

        // Check if record exists
        $existing = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
            "SELECT * FROM $table WHERE element_id = %d AND element_type = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
            $post_id, $post_type
        ));

        if ($existing) {
            // Update language
            $wpdb->update( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
                $table,
                ['language_code' => $language],
                ['id' => $existing->id]
            );
        } else {
            // Create new translation group
            $max_group = $wpdb->get_var("SELECT MAX(translation_group) FROM $table"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
            $new_group = ($max_group ?: 0) + 1;

            $wpdb->insert($table, [ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
                'element_id' => $post_id,
                'element_type' => $post_type,
                'translation_group' => $new_group,
                'language_code' => $language,
            ]);
        }

        // invalidate caches for this post — we just mutated its row
        // in wp_lf_translations. Without this, a subsequent link_translations
        // call (in the same request) reads a stale group_cache[$post_id]=null
        // from an earlier get_translations hit and inserts a DUPLICATE row
        // with a second group, leaving the post in two translation_groups
        // simultaneously. The WooCommerce "Create translation" flow is the
        // canonical repro: save_post_product → sync_product_meta calls
        // get_translations(new_id) during wp_insert_post — that caches the
        // new post as "no group". update_translation_record then inserts a
        // real row, but the cache never learns about it.
        $this->invalidate_post_cache($post_id);
    }

    /**
 * Drop cached translations + group for a single post, so the next
 * get_translations call hits the DB. Call after any write to
 * wp_lf_translations that affects this post.
 */
    private function invalidate_post_cache($post_id) {
        $post_id = (int) $post_id;
        unset($this->translations_cache[$post_id]);
        unset($this->group_cache[$post_id]);
    }

    /**
 * Drop the entire in-memory cache. Use when a mutation affects a whole
 * group (merge / unlink), since the sibling posts' cached translation
 * arrays also need to forget the old group shape.
 */
    private function invalidate_all_cache() {
        $this->translations_cache = [];
        $this->group_cache = [];
        $this->bulk_preloaded = false;
    }
    
    public function link_translations($post_id_1, $post_id_2) {
        global $wpdb;
        $table = $wpdb->prefix . 'lf_translations';

        $group1 = $this->get_translation_group($post_id_1);
        $group2 = $this->get_translation_group($post_id_2);

        if ($group1 && $group2 && $group1 !== $group2) {
            // Merge groups - update all items in group2 to group1
            $wpdb->update( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
                $table,
                ['translation_group' => $group1],
                ['translation_group' => $group2]
            );
        } elseif ($group1 && !$group2) {
            // Add post_id_2 to group1
            $lang2 = $this->get_post_language($post_id_2);
            $wpdb->insert($table, [ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
                'element_id' => $post_id_2,
                'element_type' => get_post_type($post_id_2),
                'translation_group' => $group1,
                'language_code' => $lang2,
            ]);
        } elseif (!$group1 && $group2) {
            // Add post_id_1 to group2
            $lang1 = $this->get_post_language($post_id_1);
            $wpdb->insert($table, [ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
                'element_id' => $post_id_1,
                'element_type' => get_post_type($post_id_1),
                'translation_group' => $group2,
                'language_code' => $lang1,
            ]);
        } else {
            // Create new group for both
            $max_group = $wpdb->get_var("SELECT MAX(translation_group) FROM $table"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
            $new_group = ($max_group ?: 0) + 1;

            $lang1 = $this->get_post_language($post_id_1);
            $lang2 = $this->get_post_language($post_id_2);

            $wpdb->insert($table, [ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
                'element_id' => $post_id_1,
                'element_type' => get_post_type($post_id_1),
                'translation_group' => $new_group,
                'language_code' => $lang1,
            ]);

            $wpdb->insert($table, [ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
                'element_id' => $post_id_2,
                'element_type' => get_post_type($post_id_2),
                'translation_group' => $new_group,
                'language_code' => $lang2,
            ]);
        }

        // link/merge changes the group shape for every sibling in
        // both groups, not just post_id_1 / post_id_2. Nuke the whole cache
        // so subsequent get_translations calls see the fresh topology.
        $this->invalidate_all_cache();
    }
    
    public function unlink_translation($post_id, $language) {
        global $wpdb;
        $table = $wpdb->prefix . 'lf_translations';

        $group = $this->get_translation_group($post_id);
        if (!$group) {
            return;
        }

        // Remove the translation from the group
        $wpdb->delete($table, [ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
            'translation_group' => $group,
            'language_code' => $language,
        ]);

        // same group-wide invalidation as link_translations.
        $this->invalidate_all_cache();
    }
    
    // AJAX: Create translation
    public function ajax_create_translation() {
        check_ajax_referer('lf_admin_nonce', 'nonce');

        $post_id = intval($_POST['post_id'] ?? 0);
        if (!$post_id || !current_user_can('edit_post', $post_id)) {
            wp_send_json_error(['message' => __('Forbidden', 'langforge')], 403);
        }

        $target_lang = sanitize_text_field(wp_unslash($_POST['language'] ?? ''));
        if ($err = $this->require_language_available($target_lang)) {
            wp_send_json_error(['message' => $err->get_error_message()], 403);
        }
        
        $original_post = get_post($post_id);
        if (!$original_post) {
            wp_send_json_error(['message' => __('Original post not found', 'langforge')]);
        }
        
        $url_format = get_option('lf_url_format', 'directory');
        
        // Create new post
        $new_post_data = [
            'post_title' => $original_post->post_title,
            'post_content' => $original_post->post_content,
            'post_excerpt' => $original_post->post_excerpt,
            'post_status' => 'draft',
            'post_type' => $original_post->post_type,
            'post_author' => get_current_user_id(),
        ];
        
        // For both formats, use same slug - WP will auto-suffix if conflict exists
        // In directory format, different language prefixes prevent URL conflicts
        // In parameter format, ?lang= differentiates the posts
        $new_post_data['post_name'] = $original_post->post_name;
        
        $new_post_id = wp_insert_post($new_post_data);
        
        if (is_wp_error($new_post_id)) {
            wp_send_json_error(['message' => $new_post_id->get_error_message()]);
        }
        
        // Set language and link translations
        $this->set_post_language($new_post_id, $target_lang);
        $this->link_translations($post_id, $new_post_id);

        // v9 (2026-05-03): write LearnDash relationship-meta SYNCHRONOUSLY
        // here, before generic meta copy and before lf_translation_created hook
        // chain. Sergei (11x retest) called out the architectural gap:
        // shutdown / save_post / scanner are RECOVERY mechanisms — by the time
        // they run, the data has already been wrong for an arbitrary window.
        // The mapper writes the remapped values via direct $wpdb upsert with
        // read-back verify and up to 3 retries, so a SELECT immediately after
        // wp_insert_post sees the correct EN-id values. No deferred work.
        // LearnDash mapper lives in langforge-pro. On Free-only sites the
        // class is absent → the relationship-meta write is skipped (LearnDash
        // integration is a Pro feature).
        if (class_exists('LF_LearnDash_Mapper') && LF_LearnDash_Mapper::is_learndash_active()) {
            LF_LearnDash_Mapper::apply($post_id, $new_post_id, $target_lang);
        }

        // Copy all custom meta (ACF fields, SEO meta, etc.)
        // Direct meta copy is the most reliable method for ACF repeaters/flexible content
        $source_meta = get_post_meta($post_id);
        $skip_keys = array_merge([
            '_edit_lock', '_edit_last',
            '_lf_language', '_lf_source_snapshot', '_lf_needs_update',
            '_pingme', '_encloseme',
            '_elementor_data',
        ],
        // v9: relationship keys MUST be sourced from
        // LF_LearnDash_Mapper::skip_keys_for_generic_copy so the
        // gen-copy paths can't drift from the mapper's contract.
        // On Free-only sites the mapper isn't loaded → no extra skip keys.
        class_exists('LF_LearnDash_Mapper')
            ? LF_LearnDash_Mapper::skip_keys_for_generic_copy()
            : []);
        foreach ($source_meta as $key => $values) {
            if (in_array($key, $skip_keys)) continue;
            // Skip keys already set on the new post (like _lf_language)
            if (get_post_meta($new_post_id, $key, true) !== '') continue;
            foreach ($values as $val) {
                update_post_meta($new_post_id, $key, $val);
            }
        }
        
        // Elementor compatibility: copy data from SOURCE, regenerate IDs (, )
        // Read raw JSON from source post (not from new post — generic meta copy corrupts Unicode)
        $elementor_data = get_post_meta($post_id, '_elementor_data', true);
        if ($elementor_data) {
            // Ensure edit mode is set so Elementor editor can open the translated page
            update_post_meta($new_post_id, '_elementor_edit_mode', 'builder');

            // Regenerate unique element IDs to prevent duplicates
            $data = is_string($elementor_data) ? $elementor_data : wp_json_encode($elementor_data, JSON_UNESCAPED_UNICODE);
            $data = preg_replace_callback('/"id"\s*:\s*"([a-f0-9]+)"/', function () {
                return '"id":"' . dechex(random_int(0x1000000, 0xFFFFFFF)) . '"';
            }, $data);
            update_post_meta($new_post_id, '_elementor_data', wp_slash($data));
            delete_post_meta($new_post_id, '_elementor_element_cache');

            // Copy Elementor CSS cache settings so styles render
            $elementor_css = get_post_meta($post_id, '_elementor_css', true);
            if ($elementor_css) {
                update_post_meta($new_post_id, '_elementor_css', $elementor_css);
            }
        }

        // Copy featured image
        $thumbnail_id = get_post_thumbnail_id($post_id);
        if ($thumbnail_id) {
            set_post_thumbnail($new_post_id, $thumbnail_id);
        }
        
        // Copy taxonomies
        $taxonomies = get_object_taxonomies($original_post->post_type);
        foreach ($taxonomies as $taxonomy) {
            $terms = wp_get_object_terms($post_id, $taxonomy, ['fields' => 'ids']);
            if (!is_wp_error($terms)) {
                wp_set_object_terms($new_post_id, $terms, $taxonomy);
            }
        }

        // Save source content snapshot for future diff tracking. Content Diff
        // lives in langforge-pro — Free-only sites skip the snapshot (Content
        // Diff is a Pro feature anyway).
        if (class_exists('LF_Content_Diff')) {
            LF_Content_Diff::save_initial_snapshot($post_id, $new_post_id);
        }

        // Fire action for integrations (WooCommerce, Elementor, etc.)
        do_action('lf_translation_created', $post_id, $new_post_id, $target_lang); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility

        wp_send_json_success([
            'post_id' => $new_post_id,
            'edit_url' => get_edit_post_link($new_post_id, 'raw'),
            'message' => __('Translation created successfully', 'langforge'),
        ]);
    }
    
    // AJAX: Link existing translation
    public function ajax_link_translation() {
        check_ajax_referer('lf_admin_nonce', 'nonce');

        $post_id = intval($_POST['post_id'] ?? 0);
        $target_post_id = intval($_POST['target_post_id'] ?? 0);
        if (!$post_id || !$target_post_id
            || !current_user_can('edit_post', $post_id)
            || !current_user_can('edit_post', $target_post_id)) {
            wp_send_json_error(['message' => __('Forbidden', 'langforge')], 403);
        }

        $target_lang = sanitize_text_field(wp_unslash($_POST['language'] ?? ''));
        if ($err = $this->require_language_available($target_lang)) {
            wp_send_json_error(['message' => $err->get_error_message()], 403);
        }
        
        // Set language for target post
        $this->set_post_language($target_post_id, $target_lang);
        
        // Link translations
        $this->link_translations($post_id, $target_post_id);
        
        wp_send_json_success([
            'message' => __('Translation linked successfully', 'langforge'),
        ]);
    }
    
    // AJAX: Unlink translation
    public function ajax_unlink_translation() {
        check_ajax_referer('lf_admin_nonce', 'nonce');

        $post_id = intval($_POST['post_id'] ?? 0);
        if (!$post_id || !current_user_can('edit_post', $post_id)) {
            wp_send_json_error(['message' => __('Forbidden', 'langforge')], 403);
        }

        $language = sanitize_text_field(wp_unslash($_POST['language'] ?? ''));
        
        $this->unlink_translation($post_id, $language);
        
        wp_send_json_success([
            'message' => __('Translation unlinked successfully', 'langforge'),
        ]);
    }

    /**
 * AJAX: AI translate a post into its translation
 * Takes source post → translates title + content → saves into target post
 */
    /**
 * Translate all text-capable Field Forge values from source post to
 * target post. Returns count of successfully translated rows.
 *
 * Field Forge's storage table schema (wp_fieldforge_values):
 * id, post_id, field_group_id, field_name, field_key, field_value,
 * field_type, parent_id, row_index, layout
 *
 * field_type whitelist: text, textarea, wysiwyg — the UI surfaces that
 * are text-editable. Skip file/image/color_picker/select/page_link and
 * container types (group/repeater/flexible_content — their field_value
 * is a child count, not translatable).
 *
 * Row matching between source and target posts: we can't match by
 * parent_id because that's a DB row id local to each post. Instead
 * build a "logical path" per row by climbing the parent chain:
 *
 * textblock[0] ← top-level wysiwyg
 * blocks_sizes[0].editor[0] ← flex-content row → nested wysiwyg
 *
 * Paths are stable across post copies because Lang Forge's
 * create_translation duplicates Field Forge rows preserving
 * field_name + row_index ordering.
 */
    private function translate_fieldforge_values($table, $source_id, $target_id, $ai, $source_lang, $target_lang) {
        global $wpdb;

        $load = function ($pid) use ($wpdb, $table) {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Related Forge table from $wpdb->prefix, value is %-prepared.
            $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- related Forge table from $wpdb->prefix; direct query intended.
                "SELECT id, field_name, field_value, field_type, parent_id, row_index
                 FROM $table WHERE post_id = %d",
                $pid
            ));
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $by_id = [];
            foreach ($rows as $r) $by_id[(int) $r->id] = $r;

            // Compute logical path for each row.
            $path_to_row = [];
            foreach ($rows as $r) {
                $parts = [];
                $cur = $r;
                // Safety cap to avoid infinite loops on corrupt data.
                for ($depth = 0; $cur && $depth < 32; $depth++) {
                    array_unshift($parts, $cur->field_name . '[' . (int) $cur->row_index . ']');
                    $pid = (int) $cur->parent_id;
                    $cur = ($pid && isset($by_id[$pid])) ? $by_id[$pid] : null;
                }
                $path = implode('.', $parts);
                $path_to_row[$path] = $r;
            }
            return $path_to_row;
        };

        $src_by_path = $load($source_id);
        $tgt_by_path = $load($target_id);
        if (!$src_by_path) return 0;

        // Build translation queue from source rows — whitelist text types,
        // apply same quality filter as postmeta loop so non-translatable
        // junk doesn't bloat the API call.
        $translatable_types = ['text', 'textarea', 'wysiwyg'];
        $queue = [];
        foreach ($src_by_path as $path => $row) {
            if (!in_array($row->field_type, $translatable_types, true)) continue;
            $v = (string) $row->field_value;
            if ($v === '' || mb_strlen($v) < 4) continue;
            if (is_numeric($v)) continue;
            if (is_serialized($v)) continue;
            if (!preg_match('/\p{L}/u', $v)) continue;
            if (self::looks_non_translatable($v)) continue;
            $queue[$path] = $v;
        }
        if (empty($queue)) return 0;

        $segments = [];
        foreach ($queue as $path => $v) {
            $segments[] = ['key' => $path, 'text' => $v];
        }
        $bulk = $ai->translate_bulk($segments, $source_lang, $target_lang);
        if (is_wp_error($bulk) || !is_array($bulk)) return 0;

        $count = 0;
        foreach ($bulk as $path => $translated) {
            if (empty($translated)) continue;
            if (!isset($tgt_by_path[$path])) continue;  // target missing this row (structure drift)
            $target_row_id = (int) $tgt_by_path[$path]->id;
            $ok = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Related Forge table; custom plugin table, direct write intended.
                $table,
                ['field_value' => $translated],
                ['id' => $target_row_id],
                ['%s'],
                ['%d']
            );
            if ($ok !== false) $count++;
        }
        return $count;
    }

    /**
 * Heuristic: does $val look like something the AI shouldn't be asked
 * to translate? Matches HEX colours, JSON blobs, URLs, file paths,
 * CSS/style values, enum-like single tokens. These used to sneak into
 * translate_bulk and crash the whole batch on empty-response retry.
 */
    private static function looks_non_translatable($val) {
        // Whitespace-trimmed for pattern checks.
        $t = trim($val);

        // HEX colour: #fff or #ffffff or #ffffffff
        if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $t)) return true;

        // Starts with JSON opening (object or array of scalars).
        if ($t !== '' && ($t[0] === '{' || $t[0] === '[')) {
            $decoded = json_decode($t, true);
            if (is_array($decoded)) return true;
        }

        // URL / protocol-less URL / email.
        if (preg_match('#^(https?:)?//#', $t)) return true;
        if (preg_match('/^[\w.\-+]+@[\w.\-]+\.[a-z]{2,}$/i', $t)) return true;

        // File path (server FS or WP uploads).
        if (preg_match('#^/[\w\-./]+\.(jpe?g|png|gif|webp|svg|mp4|mp3|pdf|zip)$#i', $t)) return true;

        // CSS-value-ish: rgb(...), rgba(...), calc(...), N(px|em|rem|%).
        if (preg_match('/^(rgba?|hsla?|calc|var|url)\s*\(/i', $t)) return true;
        if (preg_match('/^-?\d+(\.\d+)?(px|em|rem|%|pt|vh|vw|ch|ex)$/i', $t)) return true;

        // Single-word enum/slug/ID (no spaces, no punctuation except -_,
        // shorter than 30 chars) — things like "video", "marketplace",
        // "my-slug", "published". These ARE technically translatable but
        // they're almost always stored as programmatic values; translating
        // them breaks conditional logic / URL slugs.
        if (preg_match('/^[a-z][a-z0-9_-]{1,29}$/', $t) && strpos($t, ' ') === false) {
            return true;
        }

        return false;
    }

    public function ajax_ai_translate_post() {
        check_ajax_referer('lf_admin_nonce', 'nonce');

        $source_id   = intval($_POST['source_id'] ?? 0);
        $target_id   = intval($_POST['target_id'] ?? 0);
        if (!$source_id || !$target_id
            || !current_user_can('edit_post', $source_id)
            || !current_user_can('edit_post', $target_id)) {
            wp_send_json_error(['message' => __('Forbidden', 'langforge')], 403);
        }

        // AI translation lives in langforge-pro. On Free-only sites the
        // class is absent — return a clean error rather than fataling.
        if (!class_exists('LF_AI_Client')) {
            wp_send_json_error(['message' => __('AI translation requires the Lang Forge Pro add-on.', 'langforge')]);
        }
        $ai = LF_AI_Client::instance();
        if (!$ai->is_configured()) {
            wp_send_json_error(['message' => __('AI translation not configured. Go to Lang Forge → Settings.', 'langforge')]);
        }
        // Enforce the license server-side. is_configured only proves a key is
        // cached — after a downgrade (license revoked, add-on still active) that
        // key lingers, so without this check a lapsed user could keep spending
        // AI credits. can('ai_translation') is false on Free / expired plans.
        if (!class_exists('LF_License') || !LF_License::can('ai_translation')) {
            wp_send_json_error(['message' => __('AI translation requires an active Lang Forge Pro license.', 'langforge')], 403);
        }

        $source_lang = sanitize_text_field(wp_unslash($_POST['source_lang'] ?? ''));
        $target_lang = sanitize_text_field(wp_unslash($_POST['target_lang'] ?? ''));

        if (!$source_lang || !$target_lang) {
            wp_send_json_error(['message' => __('Missing required parameters.', 'langforge')]);
        }

        $source = get_post($source_id);
        $target = get_post($target_id);
        if (!$source || !$target) {
            wp_send_json_error(['message' => __('Post not found.', 'langforge')]);
        }

        $translated_count = 0;
        // remember the first hard AI error (e.g. forge-api
        // daily_limit_exceeded) so a failed run surfaces a real error instead of
        // a false "Translated 0 fields successfully".
        $ai_error = null;

        // Translate title
        $title_result = $ai->translate($source->post_title, $source_lang, $target_lang, 'page/post title');
        if (is_wp_error($title_result) && $title_result->get_error_code() === 'credits_exhausted') {
            $err_data = $title_result->get_error_data();
            wp_send_json_error([
                'message'    => $title_result->get_error_message(),
                'error'      => 'credits_exhausted',
                'remaining'  => $err_data['remaining'] ?? 0,
                'reset_date' => $err_data['reset_date'] ?? '',
            ]);
        }
        if (!is_wp_error($title_result) && !empty($title_result)) {
            wp_update_post(['ID' => $target_id, 'post_title' => $title_result]);
            $translated_count++;
        } elseif (is_wp_error($title_result)) {
            $ai_error = $ai_error ?: $title_result;
        }

        // Translate content
        if (!empty($source->post_content)) {
            $content_result = $ai->translate($source->post_content, $source_lang, $target_lang, 'blog post/page content with HTML');
            if (!is_wp_error($content_result) && !empty($content_result)) {
                wp_update_post(['ID' => $target_id, 'post_content' => $content_result]);
                $translated_count++;
            } elseif (is_wp_error($content_result)) {
                $ai_error = $ai_error ?: $content_result;
            }
        }

        // Translate excerpt
        if (!empty($source->post_excerpt)) {
            $excerpt_result = $ai->translate($source->post_excerpt, $source_lang, $target_lang, 'post excerpt/summary');
            if (!is_wp_error($excerpt_result) && !empty($excerpt_result)) {
                wp_update_post(['ID' => $target_id, 'post_excerpt' => $excerpt_result]);
                $translated_count++;
            }
        }

        // Translate ALL text postmeta in ONE batch API call
        $source_meta = get_post_meta($source_id);
        $skip_meta = ['_edit_lock', '_edit_last', '_wp_page_template', '_lf_language', '_lf_source_snapshot',
                       '_lf_needs_update', '_pingme', '_encloseme', '_seoforge_focus_keyword',
                       '_seoforge_meta_title', '_seoforge_meta_description', '_seoforge_schema_type',
                       '_wpml_word_count', '_wpml_media_featured', '_wpml_media_duplicate',
                       '_thumbnail_id', '_wp_attached_file', '_wp_attachment_metadata',
                       '_wp_old_date', '_wp_old_slug'];

        $meta_to_translate = [];
        foreach ($source_meta as $key => $values) {
            if (in_array($key, $skip_meta, true)) continue;
            $val = $values[0] ?? '';
            // ACF field-reference anchors: _fieldname → 'field_abc123'. Skip.
            if (strpos($key, '_') === 0 && is_string($val) && strpos($val, 'field_') === 0) continue;
            // Quick rejects: numeric IDs, serialized arrays, empty.
            if (!is_string($val)) continue;
            if (is_numeric($val)) continue;
            if (is_serialized($val)) continue;
            if ($val === '' || mb_strlen($val) < 4) continue;
            // Must contain at least one letter (HEX codes, pure digits,
            // punctuation-only all fail this).
            if (!preg_match('/\p{L}/u', $val)) continue;

            // Extra non-translatable patterns that used to slip through
            // and crash translate_bulk. Reported 2026-04-17.
            if (self::looks_non_translatable($val)) continue;

            $meta_to_translate[$key] = $val;
        }

        if (!empty($meta_to_translate)) {
            // Use bulk translate — single API call for all fields
            $segments = [];
            foreach ($meta_to_translate as $key => $val) {
                $segments[] = ['key' => $key, 'text' => $val];
            }
            $bulk_result = $ai->translate_bulk($segments, $source_lang, $target_lang);
            if (!is_wp_error($bulk_result) && is_array($bulk_result)) {
                foreach ($bulk_result as $key => $translated_val) {
                    if (!empty($translated_val)) {
                        update_post_meta($target_id, $key, $translated_val);
                        $translated_count++;
                    }
                }
            }
        }

        // ── Field Forge integration ──────────────────────────────
        // Field Forge stores values in its own wp_fieldforge_values table —
        // NOT wp_postmeta. Our postmeta loop above misses every Field Forge
        // field (text/textarea/wysiwyg inside groups, repeaters, flexible
        // content). Without this block, testers see "translation complete"
        // but Field Forge WYSIWYG editors keep the source-language text.
        // Reported 2026-04-17 on a tester site.
        
        // Matching algorithm: since parent_id is a DB row id (local to each
        // post), source and target rows share structure but not ids. Build
        // a "logical path" per row — chain of (field_name[row_index]) up
        // the parent tree — and match source→target rows by this path.
        global $wpdb;
        $ff_table = $wpdb->prefix . 'fieldforge_values';
        $ff_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $ff_table)) === $ff_table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- related Forge table existence check
        if ($ff_exists) {
            $translated_count += $this->translate_fieldforge_values(
                $ff_table, $source_id, $target_id, $ai, $source_lang, $target_lang
            );
        }

        // Translate SEO meta if SEO Forge is active
        $seo_title = get_post_meta($source_id, '_seoforge_meta_title', true);
        if ($seo_title) {
            $seo_title_t = $ai->translate($seo_title, $source_lang, $target_lang, 'SEO meta title');
            if (!is_wp_error($seo_title_t)) update_post_meta($target_id, '_seoforge_meta_title', $seo_title_t);
        }
        $seo_desc = get_post_meta($source_id, '_seoforge_meta_description', true);
        if ($seo_desc) {
            $seo_desc_t = $ai->translate($seo_desc, $source_lang, $target_lang, 'SEO meta description');
            if (!is_wp_error($seo_desc_t)) update_post_meta($target_id, '_seoforge_meta_description', $seo_desc_t);
        }

        // a successful re-translate brings the translation back in
        // sync with the source. Refresh the snapshot used by Content Diff
        // and clear the outdated flag so the post stops appearing in the
        // Translation Status "Out of Date" tile/list. Without this, the
        // user re-translates and the row still shows up flagged.
        if ($translated_count > 0) {
            $fresh_source = get_post($source_id);
            if ($fresh_source) {
                update_post_meta($target_id, '_lf_source_snapshot', [
                    'title'   => (string) $fresh_source->post_title,
                    'content' => (string) $fresh_source->post_content,
                ]);
            }
            delete_post_meta($target_id, '_lf_needs_update');
        }

        // never report a failed/empty run as success. If nothing was
        // translated, surface the real reason (e.g. a forge-api limit) so the UI
        // shows an actionable error instead of a green "Translated 0 fields
        // successfully" + reload (which looked like the content was wiped).
        if ($translated_count === 0) {
            $detail = ($ai_error instanceof WP_Error) ? (string) $ai_error->get_error_message() : '';
            if ($detail !== '' && stripos($detail, 'daily_limit') !== false) {
                $msg = __('AI daily translation limit reached. Please try again later or upgrade your plan.', 'langforge');
            } elseif ($detail !== '') {
                /* translators: %s: the underlying AI error message. */
                $msg = sprintf(__('AI translation failed: %s', 'langforge'), $detail);
            } else {
                $msg = __('AI translation returned no content — nothing was changed. Check your AI credits/limits and try again.', 'langforge');
            }
            wp_send_json_error(['message' => $msg]);
        }

        wp_send_json_success([
            /* translators: %d: number of fields that were translated. */
            'message'    => sprintf(__('Translated %d fields successfully.', 'langforge'), $translated_count),
            'target_id'  => $target_id,
            'edit_url'   => get_edit_post_link($target_id, 'raw'),
        ]);
    }
}
