<?php
/**
 * Core class - manages languages, settings, database
 * 
 * @package LangForge
 */

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

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

class LF_Core {
    
    private static $instance = null;
    private $current_language = null;
    private $default_language = null;
    private $active_languages = [];
    private $all_languages = [];
    // Reentrancy guard: set while LangForge is itself building a localized URL
    // (get_language_url / localize_url). It stops our own home_url / term_link
    // filters from re-entering — which would recurse or corrupt the switcher's
    // default-language link with the current language.
    private $in_url_filter = false;
    
    public static function instance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    
    private function __construct() {
        $this->load_settings();
        $this->init_hooks();
    }
    
    private function init_hooks() {
        add_action('init', [$this, 'detect_language'], 1);
        add_filter('locale', [$this, 'filter_locale']);

        // LF-RTL: when the current language is right-to-left (Arabic / Hebrew /
        // Persian / Urdu / Kurdish), make WordPress + every theme automatically
        // render in RTL mode — without the site owner needing to install an
        // RTL stylesheet or patch theme templates. Mirrors WPML's behaviour.
        add_filter('is_rtl', [$this, 'filter_is_rtl']);
        add_filter('language_attributes', [$this, 'filter_language_attributes'], 10, 2);
        add_filter('body_class', [$this, 'filter_body_class']);
        add_filter('admin_body_class', [$this, 'filter_admin_body_class']);
        add_action('template_redirect', [$this, 'setup_language_cookie']);
        add_action('wp', [$this, 'preload_visible_translations']);
        add_action('wp', [$this, 'normalize_translated_singular_query'], 8);
        // normalize $wp_query for translated front pages so
        // is_front_page returns true on both /{lang}/ and
        // /{lang}/{translated-slug}/ — before template_include runs, so
        // code paths like SEO canonical URL generation and theme hooks
        // firing on 'wp' itself see a consistent answer.
        add_action('wp', [$this, 'normalize_translated_front_page_query'], 9);
        add_filter('wp_get_nav_menu_items', [$this, 'preload_menu_translations'], 1, 3);
        add_action('admin_bar_menu', [$this, 'add_admin_bar_switcher'], 100);
        
        // Filter posts by language
        add_action('pre_get_posts', [$this, 'resolve_translated_front_page'], 5);
        add_action('pre_get_posts', [$this, 'filter_posts_by_language']);

        // Resolve translated page/post in pre_get_posts so WP finds the right ID
        add_action('pre_get_posts', [$this, 'resolve_translated_singular'], 1);

        // Filter single post query to get correct language version
        add_filter('posts_where', [$this, 'filter_single_post_by_language'], 10, 2);
        
        // Rewrite rules for language prefix (directory format).
        // priority 20 — after themes/plugins register their CPTs
        // (default init=10). Without this, add_rewrite_rules can't see
        // CPTs like `work` and builds no language-prefixed rules for them,
        // so /en/works/slug/ falls through to the catch-all pagename branch,
        // 404s, and gets bounced to /en/.
        add_action('init', [$this, 'add_rewrite_rules'], 20);
        add_action('init', [$this, 'maybe_refresh_rewrite_rules'], 99);
        add_filter('query_vars', [$this, 'add_query_vars']);
        
        // Fix template selection when language parameter is present
        add_filter('template_include', [$this, 'fix_front_page_template'], 99);

        // Prevent WP canonical redirect from stripping language prefix 
        add_filter('redirect_canonical', [$this, 'prevent_language_root_redirect'], 10, 2);

        // Make bare home_url and taxonomy-archive links language-aware on the
        // frontend. Themes that build navigation from core functions instead of
        // WP nav menus (e.g. a hard-coded "Home" link / category dropdown) would
        // otherwise always point at the default-language URL and silently drop
        // the visitor's selected language. Post & page permalinks are already
        // handled by LF_Post_Translation::filter_permalink.
        add_filter('home_url', [$this, 'filter_home_url'], 10, 4);
        add_filter('term_link', [$this, 'filter_term_link'], 10, 3);
        
        // Switch to translated page if available
        add_action('template_redirect', [$this, 'switch_to_translated_page'], 1);

        // If a page/post 404s with a language param, redirect to homepage in that language
        add_action('template_redirect', [$this, 'redirect_404_to_home'], 2);

        // Map page_on_front and page_for_posts to translated ID for current language
        // Fixes: menu active state, breadcrumbs, is_front_page, blog archive page
        add_filter('option_page_on_front', [$this, 'translate_front_page_id']);
        add_filter('option_page_for_posts', [$this, 'translate_front_page_id']);

        // Invalidate the cached "empty languages" set when content moves.
        add_action('save_post',         [$this, 'clear_empty_languages_cache']);
        add_action('deleted_post',      [$this, 'clear_empty_languages_cache']);
        add_action('trashed_post',      [$this, 'clear_empty_languages_cache']);
        add_action('untrashed_post',    [$this, 'clear_empty_languages_cache']);
    }
    
    /**
 * Resolve translated page/post before WP query runs.
 * WordPress resolves pagename→ID before posts_where filters apply,
 * so our language filter can't change which page is loaded.
 * Instead, we detect the original page by slug, find its translation,
 * and swap the query to use page_id directly.
 */
    public function resolve_translated_singular($query) {
        if (is_admin() || !$query->is_main_query()) {
            return;
        }
        if ($this->should_defer_frontend_to_wpml()) {
            return;
        }
        if (!$this->current_language) {
            return;
        }
        // the resolver used to bail out for the default language
        // under the assumption that WordPress's own slug matching would pick
        // the right post. That only held when each language had a unique
        // slug. On sites where all translations share a single slug (common
        // when imported from WPML / Polylang) WordPress grabs whatever row
        // comes first in wp_posts — often the wrong language. So we now run
        // the resolver for every language, including the default, and let
        // it swap the query to the correct post ID for the active tongue.

        global $wpdb;
        $pagename = $query->get('pagename');
        $name = $query->get('name');
        $slug = $pagename ?: $name;

        if (empty($slug)) {
            return;
        }

        // Find the original post by slug (any language)
        $post_type = $pagename ? 'page' : ($query->get('post_type') ?: 'post');
        if ($pagename && strpos($pagename, '/') !== false) {
            // Hierarchical page path (e.g. "compare/acf-alternative"). post_name
            // only holds the final segment, so a nested pagename never matched
            // here — the request fell through to a 404 and redirect_404_to_home
            // bounced it to the language home. Resolve by full path instead so
            // nested pages switch to the correct translation.
            $orig_page = get_page_by_path($pagename, OBJECT, 'page');
            $original = $orig_page ? (object) ['ID' => $orig_page->ID] : null;
        } else {
            $original = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; resolve translation by slug
                "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s AND post_status = 'publish' LIMIT 1",
                $slug, $post_type
            ));
        }

        if (!$original) {
            return;
        }

        // Find translation in the requested language
        $translation_id = $this->get_translation($original->ID, $post_type, $this->current_language);

        if ($translation_id && $translation_id != $original->ID) {
            // Check if this is the translated page_for_posts (Blog page).
            // If so, convert to a posts archive query instead of a regular page query,
            // so WordPress renders the blog index (is_home) with the posts loop.
            $raw_page_for_posts = $this->get_raw_option('page_for_posts');
            if ($raw_page_for_posts && (int) $raw_page_for_posts === (int) $original->ID) {
                // Convert to the blog archive (posts page) for the translated language.
                // Clear singular vars so WP_Query::get_posts runs the posts loop,
                // then point queried_object at the translated blog page.
                $query->set('pagename', '');
                $query->set('page_id', '');
                $query->set('name', '');
                $query->set('p', '');
                $query->set('post_type', '');
                $query->is_page = false;
                $query->is_singular = false;
                $query->is_archive = false;
                $query->is_front_page = false;
                $query->is_home = true;
                $query->is_posts_page = true;
                $translated_page = get_post($translation_id);
                if ($translated_page) {
                    $query->queried_object = $translated_page;
                    $query->queried_object_id = (int) $translation_id;
                }
                return;
            }

            // Swap query to use the translated post ID directly
            if ($pagename) {
                $query->set('pagename', '');
                $query->set('page_id', $translation_id);
            } else {
                $query->set('name', '');
                $query->set('p', $translation_id);
            }
        } elseif ($translation_id && $translation_id == $original->ID) {
            // Same post is the translation — let WP find it normally
            return;
        }
        // If no translation exists, WP will 404 and redirect_404_to_home handles it
    }

    /**
 * Switch to translated content version if available
 */
    public function switch_to_translated_page() {
        global $post, $wp_query;
        
        if (is_admin()) {
            return;
        }
        if ($this->should_defer_frontend_to_wpml()) {
            return;
        }
        
        // Handle pages and posts - switch to translation in requested language
        if ((is_page() || is_singular()) && $this->current_language && $post) {
            $post_type = get_post_type($post);
            $post_lang = get_post_meta($post->ID, '_lf_language', true);
            
            // If current post is not in requested language, find translation
            if ($post_lang !== $this->current_language) {
                $translation_id = $this->get_translation($post->ID, $post_type, $this->current_language);
                
                // If translation exists, switch to it
                if ($translation_id && $translation_id != $post->ID) {
                    $translated_post = get_post($translation_id);
                    if ($translated_post) {
                        $post = $translated_post;
                        setup_postdata($post);
                        $wp_query->queried_object = $post;
                        $wp_query->queried_object_id = $translation_id;
                        $wp_query->post = $post;
                        $wp_query->posts = [$post];
                        $wp_query->found_posts = 1;
                        $wp_query->max_num_pages = 1;
                    }
                }
            }
            // If no translation, keep the original post (default language fallback)
        }
    }

    /**
 * Redirect 404 pages to the homepage in the current language.
 * When a visitor clicks a language switcher link but the translation
 * doesn't exist, WordPress shows a 404. WPML redirects to the homepage
 * in that language instead — we replicate this behaviour.
 */
    public function redirect_404_to_home() {
        if (!is_404() || is_admin()) {
            return;
        }
        if ($this->should_defer_frontend_to_wpml()) {
            return;
        }
        // Only redirect when a language was explicitly requested
        if (empty($this->current_language)) {
            return;
        }
        // honour the master `Use fallback languages` switch (same
        // option get_language_fallbacks checks). When the user disabled
        // fallbacks they want a real 404 on missing translations — not a
        // silent redirect to the language's homepage. Without this check
        // the toggle only affected content-fallback, not URL-fallback,
        // which made "strict multi-language mode" impossible.
        if (!get_option('lf_fallbacks_enabled', true)) {
            return;
        }
        // LF audit 2026-04-28 (cross-plugin flag from SEOForge F2):
        // exclude virtual system paths like robots.txt, sitemap*.xml,
        // feed/, wp-json/, favicon.ico from the 404→homepage redirect.
        // Subdir installs (WordPress in a subdirectory) make WP 404
        // these requests because core only registers the robots-rewrite
        // for root installs (see wp-includes/class-wp-rewrite.php). When
        // a language was explicitly set on the request we used to bounce
        // them to the homepage — bots got 302→/ instead of robots.txt,
        // SEOForge's 404 log accumulated 18 false hits. Filter out the
        // canonical system paths so SEO/feed crawlers see the real
        // resource (or a 404 from WP, which is correct for missing).
        $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
        if ($request_uri) {
            // Strip query string for path matching.
            $path = strtok($request_uri, '?');
            // Normalize: remove trailing slash for comparison; keep raw
            // for prefix checks where the trailing slash matters.
            $path_no_trailing = rtrim($path, '/');
            $basename = strtolower(basename($path_no_trailing));

            $skip_basenames = [
                'robots.txt',
                'sitemap.xml',
                'sitemap_index.xml',
                'wp-sitemap.xml',
                'favicon.ico',
                'ads.txt',
                'security.txt',
                'humans.txt',
            ];
            if (in_array($basename, $skip_basenames, true)) {
                return;
            }

            // Generic .xml / .txt at any depth (covers per-type sitemaps
            // like /post-sitemap.xml, /sitemap-news.xml, /.well-known/*,
            // and any third-party SEO XML). These should never bounce.
            if (preg_match('#\.(xml|txt)$#i', $path_no_trailing)) {
                return;
            }

            // Feed paths (/feed/, /comments/feed/, /<post>/feed/, ?feed=rss).
            if (preg_match('#/feed/?$#i', $path_no_trailing)
                || preg_match('#/comments/feed/?$#i', $path_no_trailing)
                || sanitize_text_field(wp_unslash($_GET['feed'] ?? '')) !== '') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only feed-path detection for redirect routing, no data mutation.
                return;
            }

            // REST API root: any request that hits /wp-json/* should
            // never be hijacked by the language redirect.
            if (preg_match('#/wp-json($|/)#i', $path_no_trailing)) {
                return;
            }

            // .well-known paths (security.txt, change-password, etc.)
            if (strpos($path_no_trailing, '/.well-known/') !== false) {
                return;
            }
        }
        $default_lang = $this->get_default_language();
        $home = home_url('/');
        $url_format = get_option('lf_url_format', 'directory');
        $hide_default = get_option('lf_hide_default_language', false);

        if ($this->current_language === $default_lang && $hide_default) {
            $redirect_url = $home;
        } elseif ($url_format === 'subdomain' || $url_format === 'domain') {
            // For host-based modes, redirect to root of current host
            $redirect_url = $this->get_language_url($this->current_language);
            // Ensure we go to the root, not the current path
            $parsed = wp_parse_url($redirect_url);
            $redirect_url = $parsed['scheme'] . '://' . $parsed['host'];
            if (isset($parsed['port'])) $redirect_url .= ':' . $parsed['port'];
            $redirect_url .= '/';
        } elseif ($url_format === 'directory') {
            $redirect_url = home_url('/' . $this->current_language . '/');
        } else {
            $redirect_url = add_query_arg('lang', $this->current_language, $home);
        }

        wp_safe_redirect($redirect_url, 302);
        exit;
    }

    /**
 * Get translation ID for any post type, with fallback chain support.
 * Delegates to LF_Post_Translation cached methods to avoid N+1 queries.
 */
    public function get_translation($post_id, $post_type, $language) {
        // Use the cached LF_Post_Translation lookup (no raw SQL)
        $translation_id = LF_Post_Translation::instance()->get_translation((int) $post_id, $language);

        if ($translation_id) {
            return (int) $translation_id;
        }

        // Try fallback chain
        $fallbacks = self::get_language_fallbacks($language);
        foreach ($fallbacks as $fallback_lang) {
            $fallback_id = LF_Post_Translation::instance()->get_translation((int) $post_id, $fallback_lang);
            if ($fallback_id) {
                return (int) $fallback_id;
            }
        }

        return null;
    }

    /**
 * Get fallback chain for a language.
 *
 * respect the `lf_fallbacks_enabled` master switch. When the
 * user has disabled fallbacks explicitly we return an empty chain so
 * callers surface "no translation" instead of silently substituting
 * another language's content (strict multi-language mode).
 */
    public static function get_language_fallbacks($language) {
        if (!get_option('lf_fallbacks_enabled', true)) {
            return [];
        }
        $all_fallbacks = get_option('lf_language_fallbacks', []);
        if (isset($all_fallbacks[$language]) && is_array($all_fallbacks[$language])) {
            return $all_fallbacks[$language];
        }
        // Default: fall back to default language
        $default = get_option('lf_default_language', 'ru');
        return ($language !== $default) ? [$default] : [];
    }
    
    /**
 * Ensure front page template is used even with language parameter
 */
    public function fix_front_page_template($template) {
        global $post, $wp_query;

        $current_lang = $this->get_current_language();
        $default_lang = $this->get_default_language();

        // Only apply when viewing a non-default language at the site root
        // Works for both directory (/en/) and parameter (?lang=en) formats
        if ($current_lang === $default_lang) {
            return $template;
        }

        // Check if this is the site root (home/front_page) and not already a singular page.
        // Exclude the translated posts page — /en/blog/ is a home query but must render
        // as a posts archive, not the translated front page.
        if ($wp_query && !empty($wp_query->is_posts_page)) {
            return $template;
        }
        if ((is_home() || is_front_page()) && !is_singular()) {
            $front_page_id = (int) get_option('page_on_front');
            if (!$front_page_id || get_option('show_on_front') !== 'page') {
                return $template;
            }

            // Find the translated front page for the current language
            $translation_id = LF_Post_Translation::instance()->get_translation($front_page_id, $current_lang);
            $target_id = $translation_id ?: $front_page_id;
            $target_post = get_post($target_id);

            if (!$target_post) {
                return $template;
            }

            // Set up global post data so the page renders correctly
            $post = $target_post;
            setup_postdata($post);
            $wp_query->queried_object = $post;
            $wp_query->queried_object_id = $target_id;
            $wp_query->is_page = true;
            $wp_query->is_singular = true;
            $wp_query->is_home = false;

            // Get the template for this page
            $page_template = get_page_template_slug($target_id) ?: get_page_template_slug($front_page_id);
            if ($page_template) {
                $located = locate_template($page_template);
                if ($located) {
                    return $located;
                }
            }
            // Fallback to page-home.php or page.php
            $templates = ['page-home.php', 'page.php'];
            $located = locate_template($templates);
            if ($located) {
                return $located;
            }
        }
        return $template;
    }
    
    /**
 * Add language switcher to WordPress admin bar
 */
    public function add_admin_bar_switcher($wp_admin_bar) {
        $languages = $this->get_active_languages();
        if (count($languages) < 2) return;

        // In admin, determine the "current" language for the switcher display.
        // When an explicit ?lang= param is in the URL (placed there by a previous
        // switcher click), honour it. When no param is present we must NOT fall
        // back to the site default language, because that would cause the default
        // language to be excluded from the dropdown on every admin page — making
        // it impossible to switch back to (e.g. English is always absent when it
        // is the default). Use a sentinel value so no language is excluded.
        if (is_admin()) {
            // Use lf_lang param in admin to avoid conflict with WPML's ?lang= handling
            // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only admin UI language routing, no data mutation.
            if (!empty($_GET['lf_lang']) && isset($languages[$_GET['lf_lang']])) {
                $current = sanitize_text_field(wp_unslash($_GET['lf_lang']));
            } elseif (!empty($_GET['lang']) && isset($languages[$_GET['lang']])) {
                $current = sanitize_text_field(wp_unslash($_GET['lang']));
            } else {
                $current = '';
            }
            // phpcs:enable WordPress.Security.NonceVerification.Recommended
        } else {
            $current = $this->get_current_language();
        }
        $flag_map = (class_exists('LF_Admin') && method_exists('LF_Admin', 'get_flag_map')) ? LF_Admin::get_flag_map() : [];

        if ($current !== '') {
            $current_info = $languages[$current] ?? [];
            $flag         = $flag_map[$current_info['flag'] ?? $current] ?? '';
            $label        = strtoupper($current);
        } else {
            // No specific language active in admin — show a neutral label
            $flag  = '';
            $label = __('Lang', 'langforge');
        }

        // Parent item — current language (or neutral switcher label in admin)
        $wp_admin_bar->add_node([
            'id'    => 'lf-language-switcher',
            'title' => '<span style="display:inline-flex;align-items:center;gap:5px;">'
                     . ($flag ? '<span style="font-size:16px;line-height:1;">' . $flag . '</span>' : '')
                     . '<span style="font-size:14px;">' . esc_html($label) . '</span>'
                     . '</span>',
            'meta'  => ['class' => 'lf-adminbar-lang'],
        ]);

        // Child items — other languages
        foreach ($languages as $code => $lang) {
            if ($code === $current) continue;

            $lang_flag = $flag_map[$lang['flag'] ?? $code] ?? '';

            // Build switch URL
            if (is_admin()) {
                // Use lf_lang in admin to avoid WPML conflict with ?lang=
                $base = set_url_scheme('http://' . sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'] ?? '')) . sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '')));
                $url = add_query_arg('lf_lang', $code, remove_query_arg(['lf_lang', 'lang'], $base));
            } else {
                // On frontend: use home_url with language
                $url_format = get_option('lf_url_format', 'directory');
                $hide_default = get_option('lf_hide_default_language', false);

                if ($url_format === 'directory') {
                    if ($hide_default && $code === $this->get_default_language()) {
                        $url = home_url('/');
                    } else {
                        $url = home_url('/' . $code . '/');
                    }
                } else {
                    $url = add_query_arg('lang', $code, home_url('/'));
                }
            }

            $wp_admin_bar->add_node([
                'parent' => 'lf-language-switcher',
                'id'     => 'lf-lang-' . $code,
                'title'  => ($lang_flag ? '<span style="margin-right:4px;">' . $lang_flag . '</span>' : '')
                          . esc_html($lang['native_name'] ?? strtoupper($code)),
                'href'   => $url,
            ]);
        }
    }

    private function load_settings() {
        $this->default_language = get_option('lf_default_language', 'ru');
        $this->active_languages = get_option('lf_active_languages', [
            'ru' => ['name' => 'Русский', 'native_name' => 'Русский', 'flag' => 'ru', 'locale' => 'ru_RU'],
            'en' => ['name' => 'English', 'native_name' => 'English', 'flag' => 'gb', 'locale' => 'en_US'],
        ]);

        $this->all_languages = $this->get_available_languages();
    }

    /**
 * Public reload hook for callers that just mutated the language
 * options in the same request (Settings save handler). Without this,
 * subsequent render helpers (bulk duplicate dropdown, switcher previews)
 * see the stale snapshot captured at plugin init and the tester has to
 * reload the browser to see the new language.
 */
    public function reload_settings() {
        $this->load_settings();
    }
    
    public function detect_language() {
        $url_format = get_option('lf_url_format', 'directory');
        $active_languages = $this->get_active_languages();

        // Check subdomain format first (en.site.com)
        if ($url_format === 'subdomain') {
            $host = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'] ?? ''));
            $site_host = wp_parse_url(home_url(), PHP_URL_HOST);
            // Check if current host is a subdomain of the main site
            if ($host !== $site_host && strpos($host, '.') !== false) {
                $subdomain = explode('.', $host)[0];
                if (isset($active_languages[$subdomain])) {
                    $this->current_language = $subdomain;
                    return;
                }
            }
        }

        // Check domain mapping (en => en.site.com or en.other-domain.com)
        if ($url_format === 'domain') {
            $domain_map = get_option('lf_domain_map', []);
            $current_host = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'] ?? ''));
            foreach ($domain_map as $lang => $domain) {
                if ($current_host === $domain && isset($active_languages[$lang])) {
                    $this->current_language = $lang;
                    return;
                }
            }
        }

        // Check URL prefix (directory format: /en/page)
        // wp_parse_url returns null when the URL has no path component (site
        // installed at root without a trailing slash, or malformed REQUEST_URI).
        // PHP 8.1+ emits a deprecation on trim(null) — 12k+ events on prod
        // traced to this path (LF telemetry 2026-04-24). Cast to string so
        // the default "" flows through harmlessly.
        $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
        $path = trim((string) wp_parse_url($request_uri, PHP_URL_PATH), '/');

        // Remove site subdirectory from path
        $home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
        if ($home_path && strpos($path, $home_path) === 0) {
            $path = substr($path, strlen($home_path));
            $path = trim($path, '/');
        }

        $path_parts = explode('/', $path);

        if (!empty($path_parts[0]) && isset($active_languages[$path_parts[0]])) {
            $this->current_language = $path_parts[0];
            return;
        }
        
        // Check ?lang= parameter (for parameter format) — frontend only
        if (!is_admin() && isset($_GET['lang']) && isset($active_languages[$_GET['lang']])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only language routing from URL param, no data mutation.
            $this->current_language = sanitize_text_field(wp_unslash($_GET['lang'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only language routing from URL param, no data mutation.
            return;
        }

        // No URL-based signal present on the frontend: the URL resolves to the
        // default language. Do NOT consult the cookie — a stale lf_language=en
        // cookie must not override a clean default-language URL (/).
        // Skip directly to auto-detect (first-visit browser preference) and then
        // fall back to the site default. The cookie is only a valid signal when
        // a language prefix or ?lang= param was present in the URL.
        if (!is_admin()) {
            // Auto-detect from browser Accept-Language header (first visit, no cookie
            // fallback). When we detect a non-default language, REDIRECT to the
            // language-prefixed URL — not mutate current_language mid-render. Serving
            // RU content at the canonical EN URL breaks SEO, breaks hreflang, and
            // confuses users who explicitly typed "/" in their URL bar.
            
            // Guard: is_admin does NOT cover wp-login.php, REST, XML-RPC, or cron.
            // On those URLs the redirect breaks login / AJAX / API. Reported after
            // All-in-One-Migration imports invalidated auth cookies: wp-login.php
            // visits were 302-ing to /ru/ instead of rendering the form.
            $pagenow = $GLOBALS['pagenow'] ?? '';
            $req_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
            $is_system_request = $pagenow === 'wp-login.php'
                || $pagenow === 'wp-cron.php'
                || (defined('DOING_AJAX') && DOING_AJAX)
                || (defined('DOING_CRON') && DOING_CRON)
                || (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST)
                || strpos($req_uri, '/wp-json/') !== false
                || strpos($req_uri, '/wp-login.php') !== false
                || strpos($req_uri, '/xmlrpc.php') !== false;

            if (!$is_system_request
                && get_option('lf_auto_detect_language', false)
                && empty($_COOKIE['lf_language'])
                && empty($_COOKIE['lf_autodetect_done'])
                && (empty($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] === 'GET')
                && empty($_SERVER['HTTP_X_REQUESTED_WITH'])) {

                $detected = $this->detect_browser_language();
                if ($detected && $detected !== $this->default_language) {
                    // Mark this browser as "auto-detect already ran" so a second
                    // visit to / (after the user manually navigates back) does
                    // NOT keep redirecting.
                    setcookie('lf_autodetect_done', '1', time() + YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl());

                    $target_url = home_url('/' . $detected . '/');
                    wp_safe_redirect($target_url, 302);
                    exit;
                }
                // Detected default-lang or nothing — stay on canonical URL, mark done.
                setcookie('lf_autodetect_done', '1', time() + YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl());
            }

            $this->current_language = $this->default_language;
            return;
        }

        // Default language (admin fallthrough)
        $this->current_language = $this->default_language;
    }
    
    /**
 * Preload translation groups for main query posts.
 * Runs on 'wp' hook — only preloads main query + front page.
 * Nav menu items are preloaded separately via wp_get_nav_menu_items filter
 * to avoid double-loading menu items (26+ extra queries).
 *
 * @since 1.0.0 
 */
    public function preload_visible_translations() {
        if (is_admin()) return;

        $post_ids = [];

        // Main query posts
        global $wp_query;
        if (!empty($wp_query->posts)) {
            foreach ($wp_query->posts as $p) {
                $post_ids[] = is_object($p) ? $p->ID : (int) $p;
            }
        }

        // Front page + posts page (preload both to avoid individual queries)
        // Read raw options to bypass our own translate_front_page_id filter
        $front_page = (int) get_option('page_on_front', false);
        $posts_page = (int) get_option('page_for_posts', false);
        if ($front_page) $post_ids[] = $front_page;
        if ($posts_page) $post_ids[] = $posts_page;

        $post_ids = array_unique(array_filter($post_ids));
        if (!empty($post_ids)) {
            LF_Post_Translation::instance()->preload_translations($post_ids);
        }
    }

    /**
 * Keep WordPress's singular query object aligned with the actual post row.
 *
 * For directory URLs where translations share the same slug, the
 * posts_where language filter can make the SQL return the translated row
 * while core's queried_object_id still points at the first page matched by
 * slug. The template loop then renders the right content, but SEO/canonical
 * code that calls get_queried_object_id reads the source post. Normalize
 * the query object after the main query and before wp_head consumers run.
 */
    public function normalize_translated_singular_query() {
        if (is_admin() || $this->should_defer_frontend_to_wpml()) {
            return;
        }

        global $post, $wp_query;
        if (!$wp_query || !$wp_query->is_singular() || !$post instanceof WP_Post) {
            return;
        }

        $post_lang = get_post_meta($post->ID, '_lf_language', true);
        if ($this->current_language && $post_lang && $post_lang !== $this->current_language) {
            return;
        }

        if ((int) $wp_query->queried_object_id === (int) $post->ID
            && isset($wp_query->queried_object->ID)
            && (int) $wp_query->queried_object->ID === (int) $post->ID) {
            return;
        }

        $wp_query->queried_object    = $post;
        $wp_query->queried_object_id = (int) $post->ID;
        $wp_query->post              = $post;
        if (!empty($wp_query->posts) && isset($wp_query->posts[0])) {
            $wp_query->posts[0] = $post;
        }
    }

    /**
 * Preload translations for nav menu items when WordPress loads them.
 * Hooks into wp_get_nav_menu_items filter — fires naturally when template
 * renders wp_nav_menu, so no extra query overhead for loading menu items.
 *
 * @since 1.0.0 
 */
    public function preload_menu_translations($items, $menu, $args) {
        if (is_admin() || empty($items)) return $items;

        $post_ids = [];
        foreach ($items as $item) {
            if ($item->type === 'post_type' && $item->object_id) {
                $post_ids[] = (int) $item->object_id;
            }
        }
        if (!empty($post_ids)) {
            LF_Post_Translation::instance()->preload_translations($post_ids);
        }
        return $items;
    }

    public function setup_language_cookie() {
        if ($this->should_defer_frontend_to_wpml()) {
            return;
        }
        if (headers_sent()) {
            return;
        }

        // If the current page resolves to the default language via a clean URL
        // (no language prefix in path, no ?lang= param), clear any stale cookie
        // so it cannot override future URL-based detection.
        $active_languages = $this->get_active_languages();
        $has_url_signal = (!empty($_GET['lang']) && isset($active_languages[$_GET['lang']])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only language routing from URL param, no data mutation.
        $request_uri    = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
        $path           = trim((string) wp_parse_url($request_uri, PHP_URL_PATH), '/');
        $home_path      = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
        if ($home_path && strpos($path, $home_path) === 0) {
            $path = trim(substr($path, strlen($home_path)), '/');
        }
        $path_parts   = explode('/', $path);
        $has_url_signal = $has_url_signal || (!empty($path_parts[0]) && isset($active_languages[$path_parts[0]]));

        if (!$has_url_signal && $this->current_language === $this->default_language) {
            // Clear the cookie: expire it in the past.
            setcookie('lf_language', '', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN);
            return;
        }

        if ($this->current_language) {
            setcookie('lf_language', $this->current_language, time() + YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN);
        }
    }

    /**
 * Detect language from browser Accept-Language header
 * Parses "en-US,en;q=0.9,ru;q=0.8" and matches against active languages
 */
    private function detect_browser_language() {
        $header = sanitize_text_field(wp_unslash($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''));
        if (empty($header)) return null;

        // Parse: "en-US,en;q=0.9,ru;q=0.8,uz;q=0.7"
        $langs = [];
        foreach (explode(',', $header) as $part) {
            $part = trim($part);
            if (preg_match('/^([a-z]{2,3})(?:-[A-Za-z]{2,4})?(?:;q=([0-9.]+))?$/', $part, $m)) {
                $code = strtolower($m[1]);
                $quality = isset($m[2]) ? (float) $m[2] : 1.0;
                $langs[$code] = $quality;
            }
        }

        // Sort by quality descending
        arsort($langs);

        // Match against active languages
        $active_languages = $this->get_active_languages();
        foreach ($langs as $code => $q) {
            if (isset($active_languages[$code]) && $code !== $this->default_language) {
                return $code;
            }
        }

        return null;
    }
    
    /**
 * Filter single post queries to return correct language version
 */
    public function filter_single_post_by_language($where, $query) {
        global $wpdb;

        if (is_admin() || $query->get('lf_lang_filtered')) {
            return $where;
        }
        if ($this->should_defer_frontend_to_wpml()) {
            return $where;
        }

        // prefer $this->current_language over $_GET['lang']. In the
        // directory URL format (`/ru/works/slug/`) the rewrite rule puts
        // `lang` into query_vars — not into $_GET — so the old branch made
        // every non-parameter-format request fall back to default_language.
        // A /ru/works/{slug-ru}/ request then got WHERE _lf_language='en',
        // found zero posts, 404'd, and redirect_404_to_home bounced the
        // visitor to /ru/. current_language was already correctly resolved
        // by detect_language for every URL format we support.
        $target_lang = $this->current_language ?: $this->default_language;

        // Check if this is a single post/page query by slug
        $post_type = $query->get('post_type');
        $name = $query->get('name');
        $pagename = $query->get('pagename');

        // Language filter using EXISTS (faster than IN subquery)
        $lang_filter = $wpdb->prepare(
            " AND (
                EXISTS (SELECT 1 FROM {$wpdb->postmeta} lf_pm WHERE lf_pm.post_id = {$wpdb->posts}.ID AND lf_pm.meta_key = '_lf_language' AND lf_pm.meta_value = %s)
                OR NOT EXISTS (SELECT 1 FROM {$wpdb->postmeta} lf_pm2 WHERE lf_pm2.post_id = {$wpdb->posts}.ID AND lf_pm2.meta_key = '_lf_language')
            )",
            $target_lang
        );

        // For pages (pagename query var)
        if (!empty($pagename)) {
            $where .= $lang_filter;
            $query->set('lf_lang_filtered', true);
            return $where;
        }

        // For translatable post types (respect user settings, don't force post/page)
        $all_types = self::get_translatable_post_types();

        if (!empty($post_type) && in_array($post_type, $all_types)) {
            if (!empty($name) || !empty($query->get($post_type))) {
                $where .= $lang_filter;
                $query->set('lf_lang_filtered', true);
            }
        }

        return $where;
    }
    
    public function filter_locale($locale) {
        if ($this->should_defer_frontend_to_wpml()) {
            return $locale;
        }
        if ($this->current_language
            && isset($this->get_active_languages()[$this->current_language]['locale'])
            && is_string($this->get_active_languages()[$this->current_language]['locale'])
            && $this->get_active_languages()[$this->current_language]['locale'] !== ''
        ) {
            return $this->get_active_languages()[$this->current_language]['locale'];
        }
        return $locale;
    }

    public function add_rewrite_rules() {
        $languages = array_keys($this->get_active_languages());
        $lang_regex = implode('|', $languages);

        // Add rewrite tag
        add_rewrite_tag('%lang%', '(' . $lang_regex . ')');

        // Language prefix — homepage
        add_rewrite_rule(
            '^(' . $lang_regex . ')/?$',
            'index.php?lang=$matches[1]',
            'top'
        );

        // Language prefix — date-based post permalink: /lang/YYYY/MM/DD/postname/
        add_rewrite_rule(
            '^(' . $lang_regex . ')/([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})/([^/]+)/?$',
            'index.php?lang=$matches[1]&year=$matches[2]&monthnum=$matches[3]&day=$matches[4]&name=$matches[5]',
            'top'
        );

        // Language prefix — date-based archive: /lang/YYYY/MM/
        add_rewrite_rule(
            '^(' . $lang_regex . ')/([0-9]{4})/([0-9]{1,2})/?$',
            'index.php?lang=$matches[1]&year=$matches[2]&monthnum=$matches[3]',
            'top'
        );

        // Language prefix — category/tag: /lang/category/slug/
        add_rewrite_rule(
            '^(' . $lang_regex . ')/category/(.+?)/?$',
            'index.php?lang=$matches[1]&category_name=$matches[2]',
            'top'
        );

        add_rewrite_rule(
            '^(' . $lang_regex . ')/tag/(.+?)/?$',
            'index.php?lang=$matches[1]&tag=$matches[2]',
            'top'
        );

        // Language prefix — pagination: /lang/page/N/
        add_rewrite_rule(
            '^(' . $lang_regex . ')/page/?([0-9]{1,})/?$',
            'index.php?lang=$matches[1]&paged=$matches[2]',
            'top'
        );

        // Language prefix — pagename with pagination: /lang/pagename/page/N/
        add_rewrite_rule(
            '^(' . $lang_regex . ')/(.+?)/page/?([0-9]{1,})/?$',
            'index.php?lang=$matches[1]&pagename=$matches[2]&paged=$matches[3]',
            'top'
        );

        // explicit CPT rules for /lang/{cpt_slug}/{post_name}/.
        // Must be inserted BEFORE the catch-all below — otherwise the
        // catch-all captures the path as `pagename=cpt_slug/post_name`,
        // the slug lookup fails (CPT rows aren't pages), resolver bails,
        // WP 404s, and redirect_404_to_home bounces to /lang/. Without
        // this loop, /en/works/mt-work-8-en/ was unreachable after
        // disabling "Hide default language in URL". We also cover CPT
        // archives (/lang/works/) and paginated archives (/lang/works/page/2/).
        $public_cpts = get_post_types(['public' => true, '_builtin' => false], 'objects');
        foreach ($public_cpts as $cpt_name => $cpt) {
            if (empty($cpt->publicly_queryable)) continue;
            $cpt_slug = null;
            if (is_array($cpt->rewrite) && !empty($cpt->rewrite['slug'])) {
                $cpt_slug = $cpt->rewrite['slug'];
            }
            if (!$cpt_slug) continue;
            foreach ($this->get_cpt_rewrite_slugs($cpt_name, $cpt_slug) as $cpt_slug_variant) {
                $slug_re = preg_quote($cpt_slug_variant, '#');

                // Single: /lang/cpt_slug/post-name/
                add_rewrite_rule(
                    '^(' . $lang_regex . ')/' . $slug_re . '/([^/]+)/?$',
                    'index.php?lang=$matches[1]&post_type=' . $cpt_name . '&name=$matches[2]',
                    'top'
                );

                // Non-prefixed alias: /product/post-name/ when the active
                // product CPT rewrite slug is /products/. WooCommerce's
                // default public examples use /product/, and stale QA links
                // should resolve to product content instead of the language
                // homepage fallback.
                if ($cpt_slug_variant !== $cpt_slug) {
                    add_rewrite_rule(
                        '^' . $slug_re . '/([^/]+)/?$',
                        'index.php?post_type=' . $cpt_name . '&name=$matches[1]',
                        'top'
                    );
                }

                // Archive + pagination (only if CPT declares has_archive)
                if (!empty($cpt->has_archive)) {
                    add_rewrite_rule(
                        '^(' . $lang_regex . ')/' . $slug_re . '/page/?([0-9]{1,})/?$',
                        'index.php?lang=$matches[1]&post_type=' . $cpt_name . '&paged=$matches[2]',
                        'top'
                    );
                    add_rewrite_rule(
                        '^(' . $lang_regex . ')/' . $slug_re . '/?$',
                        'index.php?lang=$matches[1]&post_type=' . $cpt_name,
                        'top'
                    );
                    if ($cpt_slug_variant !== $cpt_slug) {
                        add_rewrite_rule(
                            '^' . $slug_re . '/page/?([0-9]{1,})/?$',
                            'index.php?post_type=' . $cpt_name . '&paged=$matches[1]',
                            'top'
                        );
                        add_rewrite_rule(
                            '^' . $slug_re . '/?$',
                            'index.php?post_type=' . $cpt_name,
                            'top'
                        );
                    }
                }
            }
        }

        // Language prefix — generic pagename (catch-all). WP's verbose
        // page rules silently skip this rule if $matches[2] isn't a page
        // slug (get_page_by_path returns null), so the next rule below
        // takes over for built-in posts.
        add_rewrite_rule(
            '^(' . $lang_regex . ')/(.+?)/?$',
            'index.php?lang=$matches[1]&pagename=$matches[2]',
            'top'
        );

        // follow-up (2026-04-23, Sergei's retest): built-in post
        // rescue. The pagename catch-all above uses `pagename=$matches[N]`
        // which WP rewrite treats as a verbose page rule — when the slug
        // isn't a page, WP silently skips the rule and keeps trying the
        // NEXT rule down the list. Without this fallback /en/mt-post-9-en/
        // fell through to WP's default `[^/]+/([^/]+)/?$` attachment rule
        // (2-segment → attachment=slug), which can't find an attachment
        // by that slug, 404s, and redirect_404_to_home bounces to /en/.
        // This rule uses `name=$matches[2]` (not `pagename=`), so it
        // isn't subject to the verbose skip — it fires exactly when
        // pagename already failed. Single-segment slugs only (`[^/]+`);
        // hierarchical /parent/child/ stays with the pagename rule.
        add_rewrite_rule(
            '^(' . $lang_regex . ')/([^/]+)/?$',
            'index.php?lang=$matches[1]&name=$matches[2]',
            'top'
        );
    }

    /**
 * CPT rewrite slugs LangForge should understand for language-prefixed
 * routes. The product post type gets a narrow WooCommerce-compatible
 * alias because many sites and docs use /product/{slug}/ while some QA
 * fixtures register the product CPT at /products/{slug}/.
 */
    private function get_cpt_rewrite_slugs($cpt_name, $primary_slug) {
        $slugs = [trim((string) $primary_slug, '/')];

        if ($cpt_name === 'product') {
            $woocommerce_permalinks = get_option('woocommerce_permalinks', []);
            if (is_array($woocommerce_permalinks) && !empty($woocommerce_permalinks['product_base'])) {
                $slugs[] = trim((string) $woocommerce_permalinks['product_base'], '/');
            }
            $slugs[] = 'product';
        }

        return array_values(array_filter(array_unique($slugs)));
    }

    /**
 * Refresh rewrite rules when the registered language/CPT shape changes.
 *
 * QA sites frequently gain new public CPTs after LangForge was already
 * activated (WooCommerce products, LearnDash courses, etc.). Without a
 * follow-up flush, WordPress keeps an old rewrite_rules snapshot that
 * doesn't know about the new routes, so translated CPT URLs fall
 * through as pagename requests and bounce to the language home page.
 */
    public function maybe_refresh_rewrite_rules() {
        if (get_option('lf_url_format', 'directory') !== 'directory') {
            return;
        }

        $signature = $this->build_rewrite_signature();
        $stored    = get_option('lf_rewrite_signature', '');
        if ($stored === $signature && !$this->rewrite_rules_missing_expected_entries()) {
            return;
        }

        update_option('lf_rewrite_signature', $signature, false);
        flush_rewrite_rules(false);
    }

    private function build_rewrite_signature() {
        $slugs = [];
        $public_types = get_post_types(['public' => true], 'objects');
        foreach ($public_types as $type => $obj) {
            if (empty($obj->publicly_queryable) || empty($obj->rewrite) || !is_array($obj->rewrite)) {
                continue;
            }
            $slug = $obj->rewrite['slug'] ?? '';
            if ($slug === '') {
                continue;
            }
            $slugs[$type] = $this->get_cpt_rewrite_slugs($type, $slug);
        }

        return md5(wp_json_encode([
            'version'      => defined('LF_VERSION') ? LF_VERSION : 'dev',
            'url_format'   => get_option('lf_url_format', 'directory'),
            'hide_default' => (bool) get_option('lf_hide_default_language', false),
            'languages'    => array_keys($this->get_active_languages()),
            'post_types'   => $slugs,
        ]));
    }

    private function rewrite_rules_missing_expected_entries() {
        $rules = get_option('rewrite_rules', []);
        if (!is_array($rules) || empty($rules)) {
            return true;
        }

        $languages = array_keys($this->get_active_languages());
        if (empty($languages)) {
            return false;
        }

        $lang_regex = implode('|', $languages);
        $expected = [
            '^(' . $lang_regex . ')/?$',
            '^(' . $lang_regex . ')/([^/]+)/?$',
            '^(' . $lang_regex . ')/(.+?)/?$',
        ];

        $public_cpts = get_post_types(['public' => true, '_builtin' => false], 'objects');
        foreach ($public_cpts as $cpt_name => $cpt) {
            if (empty($cpt->publicly_queryable) || empty($cpt->rewrite) || !is_array($cpt->rewrite)) {
                continue;
            }
            $slug = $cpt->rewrite['slug'] ?? '';
            if ($slug === '') {
                continue;
            }
            foreach ($this->get_cpt_rewrite_slugs($cpt_name, $slug) as $slug_variant) {
                $slug_re = preg_quote($slug_variant, '#');
                $expected[] = '^(' . $lang_regex . ')/' . $slug_re . '/([^/]+)/?$';
                if ($slug_variant !== $slug) {
                    $expected[] = '^' . $slug_re . '/([^/]+)/?$';
                }
                if (!empty($cpt->has_archive)) {
                    $expected[] = '^(' . $lang_regex . ')/' . $slug_re . '/?$';
                    if ($slug_variant !== $slug) {
                        $expected[] = '^' . $slug_re . '/?$';
                    }
                }
            }
        }

        foreach ($expected as $regex) {
            if (!isset($rules[$regex])) {
                return true;
            }
        }

        return false;
    }
    
    public function add_query_vars($vars) {
        $vars[] = 'lang';
        return $vars;
    }
    
    /**
 * Resolve /en/ (language-prefixed root) to the translated front page.
 *
 * WordPress rewrite for /en/ sets only ?lang=en — no page_id or pagename.
 * WP then falls back to the blog listing. This hook detects that case and
 * converts the query into a front page query for the translated page.
 */
    public function resolve_translated_front_page($query) {
        if (is_admin() || !$query->is_main_query()) {
            return;
        }
        if ($this->should_defer_frontend_to_wpml()) {
            return;
        }

        // If resolve_translated_singular already converted this query
        // into the posts archive, don't overwrite it with the front page.
        if (!empty($query->is_posts_page) || !empty($query->is_home)) {
            return;
        }

        $front_page_id = (int) get_option('page_on_front');
        if (!$front_page_id || get_option('show_on_front') !== 'page') {
            return;
        }

        $current_lang = $this->get_current_language();
        if ($current_lang === $this->default_language) {
            return;
        }

        // Detect: only 'lang' is set, nothing else (homepage for that language)
        $pagename = $query->get('pagename');
        $name = $query->get('name');
        $p = $query->get('p');
        $page_id = $query->get('page_id');

        if ($pagename || $name || $p || $page_id) {
            return; // Not a bare language root
        }

        // Find translation of front page
        $translation_id = LF_Post_Translation::instance()->get_translation($front_page_id, $current_lang);
        $target_id = $translation_id ?: $front_page_id;

        // Convert to a page query
        $query->set('page_id', $target_id);
        $query->set('post_type', 'page');
        $query->is_page = true;
        $query->is_singular = true;
        $query->is_home = false;
        $query->is_archive = false;
    }

    /**
 * Prevent WP canonical redirect from stripping language prefix on front page .
 *
 * When /en/ resolves to translated front page, WP redirect_canonical tries to
 * redirect to the "canonical" URL (site root without /en/). We must block this.
 */
    public function prevent_language_root_redirect($redirect_url, $requested_url) {
        if ($this->should_defer_frontend_to_wpml()) {
            return $redirect_url;
        }
        $current_lang = $this->get_current_language();
        if ($current_lang && $current_lang !== $this->default_language) {
            $url_format = get_option('lf_url_format', 'directory');

            // For host-based modes, never redirect away from the language host
            if ($url_format === 'subdomain' || $url_format === 'domain') {
                // If we detected a non-default language from the host, trust it — don't redirect
                return false;
            }

            if ($url_format === 'directory') {
                // Check if request is a language root (e.g. /en/, /uz/)
                $path = trim((string) wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '')), PHP_URL_PATH), '/');
                $home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
                if ($home_path) {
                    $path = preg_replace('#^' . preg_quote($home_path, '#') . '/?#', '', $path);
                }
                $path = trim($path, '/');

                // If path is just the language code — this is the language front page, don't redirect
                if ($path === $current_lang) {
                    return false;
                }

                // follow-up #2 (Sergei 2026-04-23): when the request is
                // on /lang/some-path/ and WP's canonical redirect would strip
                // the language prefix, cancel the redirect. This fixes the
                // "EN product 301 → /products/slug/" bug: WC and other CPTs
                // with duplicate slugs across languages all hit this — WP
                // computes canonical from a permalink that lost the lang
                // prefix (either because the queried post got mis-resolved
                // or because a per-plugin permalink filter like
                // woocommerce_product_get_permalink is bypassing ours), so
                // redirect_canonical then tries to "fix" the URL by stripping
                // /lang/. Detect that case and refuse. The trade-off: we
                // lose WP's automatic slug-change redirects under /lang/
                // paths, but keeping the reader on the right language beats
                // auto-forwarding to a stale URL.
                if (strpos($path, $current_lang . '/') === 0) {
                    if (!$redirect_url) {
                        return $redirect_url;
                    }
                    $redirect_path = (string) wp_parse_url($redirect_url, PHP_URL_PATH);
                    $redirect_path_stripped = $redirect_path;
                    if ($home_path) {
                        $redirect_path_stripped = preg_replace('#^' . preg_quote($home_path, '#') . '/?#', '', $redirect_path);
                    }
                    $redirect_path_stripped = trim($redirect_path_stripped, '/');

                    // If the canonical still carries the language prefix,
                    // it's a legitimate slug-change / trailing-slash redirect —
                    // let it through.
                    if (strpos($redirect_path_stripped, $current_lang . '/') === 0
                        || $redirect_path_stripped === $current_lang) {
                        return $redirect_url;
                    }
                    // Canonical stripped the language prefix → refuse.
                    return false;
                }
            }
        }
        return $redirect_url;
    }

    /**
 * Filter page_on_front option to return translated page ID.
 * This fixes: is_front_page, menu active state, breadcrumbs.
 *
 * @param mixed $page_id Original front page ID.
 * @return int Translated front page ID or original.
 */
    public function translate_front_page_id($page_id) {
        if (is_admin() || !$page_id) {
            return $page_id;
        }
        if ($this->should_defer_frontend_to_wpml()) {
            return $page_id;
        }

        $current_lang = $this->get_current_language();
        if ($current_lang === $this->default_language) {
            return $page_id;
        }

        // Prevent infinite recursion (get_translation may trigger option reads)
        // Use per-page_id flag since this filter is used for both page_on_front and page_for_posts
        static $resolving = [];
        if (!empty($resolving[$page_id])) return $page_id;
        $resolving[$page_id] = true;

        $translation_id = LF_Post_Translation::instance()->get_translation((int) $page_id, $current_lang);
        unset($resolving[$page_id]);

        return $translation_id ?: $page_id;
    }

    /**
 * normalize $wp_query on translated front pages.
 *
 * WordPress's is_front_page checks `$this->is_page(page_on_front)`
 * — which needs BOTH the is_page flag AND queried_object_id matching
 * the (filtered) page_on_front option. Three real-world URLs land the
 * user on the logical "front page" for a non-default language, but
 * only the canonical one produced consistent query flags:
 *
 * A) /{lang}/ — is_home=true, qo_id=0
 * B) /{lang}/{translated-slug}/ — qo_id set, but is_page sometimes
 * false when the slug resolution
 * path went through the * blog-page branch or similar
 * custom routing
 * C) / — default language, already fine
 *
 * fix_front_page_template partially patches case (A), but it runs on
 * template_include (priority 99). Anything that calls is_front_page
 * BEFORE template selection — SEO canonical, hreflang, theme hooks
 * on 'wp', analytics plugins — saw the un-normalized state.
 *
 * This method runs on 'wp' priority 9 (just before
 * preload_visible_translations at 10) and forces the query into the
 * shape WP expects for a static-front-page home:
 * is_page=true, is_singular=true, is_home=false, is_404=false
 * queried_object=<translated post>, queried_object_id=<translated ID>
 *
 * is_front_page is derived from is_page(page_on_front), so we don't
 * set it directly — WP computes it from the above.
 */
    public function normalize_translated_front_page_query() {
        if (is_admin()) return;
        if ($this->should_defer_frontend_to_wpml()) return;

        $current = $this->get_current_language();
        // Default language takes WP's normal path — nothing to normalize.
        if ($current === $this->default_language) return;

        // Only applies when the site actually has a static front page.
        if (get_option('show_on_front') !== 'page') return;

        // Our translate_front_page_id filter makes this return the ID for
        // the current language (or the default-language ID as fallback).
        $translated_front = (int) get_option('page_on_front');
        if (!$translated_front) return;

        global $wp_query, $post;
        if (!$wp_query) return;

        // Don't touch the blog index — 's resolve_translated_singular
        // explicitly converts the translated `page_for_posts` into a
        // posts-archive query, which must stay as is_home + is_posts_page.
        if (!empty($wp_query->is_posts_page)) return;

        $qo_id = (int) ($wp_query->queried_object_id ?? 0);

        // Two cases we normalize:
        // A) Explicit match — the resolved ID already equals the
        // translated front page. Just tighten the is_page flags so
        // is_front_page stops returning false.
        // B) Home-query shape — is_home=true (root URL resolved to
        // show_on_front=page index), queried_object not set yet.
        // Populate it from the translated front ID.
        $explicit_match = ($qo_id === $translated_front);
        $home_shape     = (empty($qo_id) && !empty($wp_query->is_home));
        if (!$explicit_match && !$home_shape) return;

        $target = get_post($translated_front);
        if (!$target || $target->post_status !== 'publish') return;

        // Swap query globals to match a clean `/?page_id={translated}` state.
        $post = $target;
        setup_postdata($post);

        $wp_query->queried_object    = $target;
        $wp_query->queried_object_id = $translated_front;
        $wp_query->post              = $target;
        $wp_query->posts             = [$target];
        $wp_query->post_count        = 1;
        $wp_query->found_posts       = 1;
        $wp_query->max_num_pages     = 1;
        $wp_query->is_page           = true;
        $wp_query->is_singular       = true;
        $wp_query->is_single         = false;
        $wp_query->is_home           = false;
        $wp_query->is_archive        = false;
        $wp_query->is_404            = false;
    }

    /**
 * Read a WP option bypassing our own filters (to get original value).
 */
    private function get_raw_option($option_name) {
        remove_filter("option_{$option_name}", [$this, 'translate_front_page_id']);
        $value = get_option($option_name);
        add_filter("option_{$option_name}", [$this, 'translate_front_page_id']);
        return $value;
    }

    public function filter_posts_by_language($query) {
        // Skip admin
        if (is_admin()) {
            return;
        }
        if ($this->should_defer_frontend_to_wpml()) {
            return;
        }
        
        // Skip if already filtered
        if ($query->get('lf_filtered') || $query->get('suppress_filters')) {
            return;
        }
        
        // Skip pages and singular post queries
        if ($query->is_page() || $query->get('page_id') || $query->get('pagename')) {
            return;
        }
        
        // Skip single post by name or ID
        if ($query->get('p') || $query->get('name')) {
            return;
        }
        
        // Get post types being queried
        $post_type = $query->get('post_type');

        // Default post type is 'post' when empty (blog index, archives)
        if (empty($post_type)) {
            $post_type = 'post';
        }

        // Only filter translatable post types (skip pages and 'any')
        $translatable_types = self::get_translatable_post_types();

        if ($post_type === 'page' || $post_type === 'any') {
            return;
        }
        
        // Check if this post type is translatable
        if (is_array($post_type)) {
            $should_filter = !empty(array_intersect($post_type, $translatable_types));
        } else {
            $should_filter = in_array($post_type, $translatable_types);
        }
        
        if (!$should_filter) {
            return;
        }
        
        // Add language filter
        $meta_query = $query->get('meta_query') ?: [];
        $meta_query[] = [
            'relation' => 'OR',
            [
                'key' => '_lf_language',
                'value' => $this->current_language,
            ],
            [
                'key' => '_lf_language',
                'compare' => 'NOT EXISTS',
            ],
        ];
        
        $query->set('meta_query', $meta_query);
        $query->set('lf_filtered', true);
    }
    
    public function filter_permalink($permalink, $post) {
        if (is_admin()) {
            return $permalink;
        }

        // only the directory URL format uses /LANG/ path segments.
        // In parameter / subdomain / domain modes the rewriter doesn't route
        // prefixed URLs, so appending /ru/ to a permalink sends the visitor
        // to a slug that doesn't resolve — the classic "EN switcher leaves
        // the reader on RU" symptom. Let those formats return the raw slug
        // permalink; the switcher itself appends ?lang= / rehosts as needed.
        $url_format = get_option('lf_url_format', 'directory');
        if ($url_format !== 'directory') {
            return $permalink;
        }

        $post_id = is_object($post) ? $post->ID : $post;
        $post_lang = get_post_meta($post_id, '_lf_language', true);

        if (!$post_lang) {
            $post_lang = $this->default_language;
        }

        // Default-language URLs may be un-prefixed when hide_default is on.
        $hide_default = get_option('lf_hide_default_language', false);
        if ($hide_default && $post_lang === $this->default_language) {
            return $permalink;
        }

        // Add language prefix (directory mode only).
        $home_url = home_url('/');
        if (strpos($permalink, $home_url) === 0) {
            $path = substr($permalink, strlen($home_url));
            $path_parts = explode('/', trim($path, '/'));
            if (!empty($path_parts[0]) && isset($this->get_active_languages()[$path_parts[0]])) {
                return $permalink;
            }
            $permalink = $home_url . $post_lang . '/' . $path;
        }

        return $permalink;
    }
    
    // Getters
    public function get_current_language() {
        return $this->current_language ?: $this->default_language;
    }

    public function get_default_language() {
        return $this->default_language;
    }

    public function get_active_languages() {
        $all = $this->active_languages;

        // Enforce Free-plan language limit by trimming the visible set
        // WITHOUT touching the saved `lf_active_languages` option. This
        // is "soft-disable": data preserved (so it reappears on Pro
        // upgrade), but frontend / hreflang / switcher only see the
        // allowed slice. Wp.org compliant — no user-data destruction.
        
        // Default language is always kept first; remaining slots filled
        // in insertion order from the saved array.
        // 2026-05-29: gate on the unlimited_languages capability (now Free)
        // rather than the whole plan, so a Free-only site keeps all active
        // languages in the effective slice.
        if (!class_exists('LF_License') || LF_License::can('unlimited_languages')) {
            return $all;
        }

        $limit = LF_License::get_limit('languages');
        if (!is_numeric($limit) || count($all) <= (int) $limit) {
            return $all;
        }

        $limit = (int) $limit;
        $default = get_option('lf_default_language');
        $kept = [];

        if ($default !== false && isset($all[$default])) {
            $kept[$default] = $all[$default];
        }

        foreach ($all as $code => $data) {
            if (count($kept) >= $limit) break;
            if (!isset($kept[$code])) {
                $kept[$code] = $data;
            }
        }

        return $kept;
    }

    /**
 * Return languages that are saved in `lf_active_languages` but
 * trimmed out of `get_active_languages` by the Free-plan limit.
 * Used by admin UI to render "locked, Pro only" entries with an
 * upgrade prompt while keeping the data on disk for restore on
 * upgrade.
 *
 * @return array<string, array> Locked-out languages keyed by code.
 */
    public function get_locked_languages() {
        $all       = $this->active_languages;
        $effective = $this->get_active_languages();
        return array_diff_key($all, $effective);
    }

    /**
 * LF-WPML: Is WPML's core (SitePress) active and configured on this site?
 *
 * Used by:
 * - admin notices that warn the site owner about the LangForge × WPML
 * coexistence conflict (both rewriting language URLs / language meta
 * in parallel produces unpredictable frontend output);
 * - defer_to_wpml_for_rtl, which hands off RTL HTML attributes to
 * WPML when WPML is the active translator (private wrapper around
 * the same check).
 *
 * Returns true only when WPML's setup is `setup_complete` — a freshly
 * installed-but-unconfigured SitePress doesn't trigger the warning
 * (no language routing yet, so no conflict yet).
 */
    public function is_wpml_active() {
        if (!class_exists('SitePress')) return false;
        $settings = get_option('icl_sitepress_settings', []);
        return !empty($settings['setup_complete']);
    }

    private function should_defer_frontend_to_wpml() {
        return !is_admin() && $this->is_wpml_active();
    }

    /**
 * LF-RTL: Is the given language code right-to-left?
 *
 * Decided from the `rtl` flag on the master language list
 * (get_available_languages), so adding a new RTL language is a single
 * data change — no filter wiring required. ar / he / fa / ur / ku
 * are the RTL languages we currently ship.
 */
    public function is_rtl_language($code) {
        $code = $code ?: $this->get_current_language();
        $list = $this->all_languages ?: $this->get_available_languages();
        return !empty($list[$code]['rtl']);
    }

    /**
 * LF-RTL: Hands the HTML-level output back to WPML when it's the active
 * translation plugin — LangForge's current_language is derived from
 * its own URL format / cookie, so on a WPML-routed site (where /ar/
 * is matched by WPML's rewrite rules, not ours) LangForge thinks the
 * visitor is still on the cookie language and would wrongly overwrite
 * WPML's correct dir/lang attributes.
 *
 * Thin wrapper around `is_wpml_active` — kept as a private alias so
 * call-site grep history stays meaningful.
 */
    private function defer_to_wpml_for_rtl() {
        return $this->is_wpml_active();
    }

    /**
 * LF-RTL: Override is_rtl when the current visitor is on an RTL
 * language. WordPress's default comes from the admin Settings → Site
 * Language locale, which ignores our per-request language switch.
 */
    public function filter_is_rtl($is_rtl) {
        // Respect explicit true upstream (another plugin / theme forcing RTL).
        if ($is_rtl) return $is_rtl;
        if ($this->defer_to_wpml_for_rtl()) return $is_rtl;
        return $this->is_rtl_language($this->get_current_language());
    }

    /**
 * LF-RTL: Ensure the <html> tag carries dir="rtl" + the right lang=""
 * when rendering a RTL translation. Without this, themes that embed
 * `<html <?php language_attributes; ?>>` output dir="ltr" even though
 * our is_rtl filter has flipped is_rtl true.
 */
    public function filter_language_attributes($output, $doctype = 'html') {
        if ($this->defer_to_wpml_for_rtl()) return $output;
        $code = $this->get_current_language();
        $info = $this->get_language_info($code);
        $rtl  = $this->is_rtl_language($code);

        // Replace dir= if present (themes often put it in), otherwise append.
        if (preg_match('/\bdir\s*=\s*"[^"]*"/', $output)) {
            $output = preg_replace('/\bdir\s*=\s*"[^"]*"/', 'dir="' . ($rtl ? 'rtl' : 'ltr') . '"', $output);
        } else {
            $output .= ' dir="' . ($rtl ? 'rtl' : 'ltr') . '"';
        }

        // Same for lang= so the browser sees the right language hint.
        $bcp47 = isset($info['locale']) ? str_replace('_', '-', $info['locale']) : $code;
        if (preg_match('/\blang\s*=\s*"[^"]*"/', $output)) {
            $output = preg_replace('/\blang\s*=\s*"[^"]*"/', 'lang="' . esc_attr($bcp47) . '"', $output);
        } else {
            $output .= ' lang="' . esc_attr($bcp47) . '"';
        }
        return $output;
    }

    /**
 * LF-RTL: add `rtl` + `lf-lang-{code}` classes to <body> so themes can
 * style RTL state with a CSS class — useful for sites without a
 * proper rtl.css.
 */
    public function filter_body_class($classes) {
        $code = $this->get_current_language();
        $classes[] = 'lf-lang-' . sanitize_html_class($code);
        if (!$this->defer_to_wpml_for_rtl()
            && $this->is_rtl_language($code)
            && !in_array('rtl', $classes, true)) {
            $classes[] = 'rtl';
        }
        return $classes;
    }

    public function filter_admin_body_class($class) {
        // Admin side stays LTR for the editing UI — but the Visual Editor
        // target column flips per-row based on the target language. So we
        // ONLY add a lang hint here, not the rtl class.
        $code = $this->get_current_language();
        return $class . ' lf-admin-lang-' . sanitize_html_class($code);
    }
    
    public function get_language_info($code) {
        $languages = $this->get_active_languages();
        return $languages[$code] ?? null;
    }
    
    public function get_languages_for_switcher($args = '') {
        $defaults = [
            'skip_missing' => 0,
            'orderby' => 'custom',
        ];
        $args = wp_parse_args($args, $defaults);

        // LF tester feedback 2026-04-18: hide languages that have zero
        // translated published content. Default off for back-compat — existing
        // sites keep rendering empty languages. Turn on via Settings →
        // Switcher → "Hide languages with no translations".
        $hide_empty = (bool) get_option('lf_switcher_hide_empty', false);
        $empty_codes = $hide_empty ? $this->get_empty_language_codes() : [];

        $languages = [];
        foreach ($this->get_active_languages() as $code => $lang) {
            // Respect show_in_switcher setting
            if (isset($lang['show_in_switcher']) && !$lang['show_in_switcher']) {
                continue;
            }
            // Skip languages with no translated content when the option is on.
            // The default language is always kept — otherwise sites with only
            // untranslated original content would render an empty switcher.
            if ($hide_empty && $code !== $this->default_language && in_array($code, $empty_codes, true)) {
                continue;
            }
            $languages[$code] = [
                'id' => $code,
                'code' => $code,
                'active' => ($code === $this->current_language) ? 1 : 0,
                'native_name' => $lang['native_name'],
                'translated_name' => $lang['name'],
                'display_name' => $lang['display_name'] ?? '',
                'language_code' => $code,
                // expose the raw country code so consumers (admin bar,
                // post-list columns) can decide between <img src=flags/X.svg>
                // and the regional-indicator emoji fallback via flag_html_inline.
                'flag' => $lang['flag'] ?? $code,
                'country_flag_url' => $this->get_flag_url($lang['flag'] ?? ''),
                'url' => $this->get_language_url($code),
            ];
        }

        return $languages;
    }
    
    public function get_flag_url($flag) {
        return LF_PLUGIN_URL . 'assets/flags/' . $flag . '.svg';
    }

    /**
 * unified flag markup for places where we embed a flag INLINE
 * (admin bar title, admin post-list columns, etc.). Uses an SVG from
 * `assets/flags/` when one exists, otherwise falls back to the Unicode
 * regional-indicator emoji pair matching the 2-letter country code.
 * Returns a ready-to-echo HTML snippet with matching width/height so
 * the two fallback paths line up visually in the same row.
 *
 * @param string $flag 2-letter country code (e.g. 'fr', 'sa', 'kz').
 * @param string $alt Alt / aria-label.
 * @param int $w Rendered width in px (default 16 for admin bar).
 * @param int $h Rendered height in px (default 11).
 */
    public function flag_html_inline($flag, $alt = '', $w = 16, $h = 11) {
        $flag = strtolower((string) $flag);
        $svg  = LF_PLUGIN_DIR . 'assets/flags/' . $flag . '.svg';
        $style_common = 'width:' . (int) $w . 'px;height:' . (int) $h
                      . 'px;vertical-align:middle;margin-right:5px;';
        if ($flag !== '' && file_exists($svg)) {
            return '<img src="' . esc_url(LF_PLUGIN_URL . 'assets/flags/' . $flag . '.svg')
                 . '" alt="' . esc_attr($alt) . '" style="' . esc_attr($style_common) . '">';
        }
        if (preg_match('/^[a-z]{2}$/', $flag)) {
            $ri = function ($ch) {
                $cp = 0x1F1E6 + (ord($ch) - ord('a'));
                return mb_convert_encoding('&#' . $cp . ';', 'UTF-8', 'HTML-ENTITIES');
            };
            $emoji = $ri($flag[0]) . $ri($flag[1]);
            // line-height tied to height so the emoji sits on the same
            // baseline as the img fallback in adjacent rows of the menu.
            $fs = max(10, (int) ($h * 1.1));
            $style = $style_common . 'display:inline-block;line-height:' . (int) $h
                   . 'px;font-size:' . $fs . 'px;text-align:center;overflow:hidden;';
            return '<span aria-label="' . esc_attr($alt) . '" style="'
                 . esc_attr($style) . '">' . $emoji . '</span>';
        }
        // Non-standard / unknown code — small muted badge so layout stays stable.
        $badge = $style_common . 'display:inline-block;line-height:' . (int) $h
               . 'px;font-size:9px;font-weight:700;color:#64748b;'
               . 'background:#e2e8f0;border-radius:2px;text-align:center;';
        return '<span style="' . esc_attr($badge) . '">'
             . esc_html(strtoupper($flag)) . '</span>';
    }

    /**
 * Return language codes that currently have zero published translated
 * posts in any of the translatable post types. Cached for one hour —
 * a tester reported that dropping content from a language didn't
 * disappear it from the switcher; the cache gets invalidated on any
 * translation save via clear_empty_languages_cache.
 */
    private $empty_languages_cache = null;
    public function get_empty_language_codes(): array {
        if ($this->empty_languages_cache !== null) return $this->empty_languages_cache;

        $cached = get_transient('lf_empty_languages');
        if (is_array($cached)) {
            $this->empty_languages_cache = $cached;
            return $cached;
        }

        global $wpdb;
        $types   = self::get_translatable_post_types();
        if (empty($types)) { $this->empty_languages_cache = []; return []; }
        $placeholders = implode(',', array_fill(0, count($types), '%s'));

        // Count published posts per language (postmeta-driven, matches how
        // the frontend filters). Default-language posts without the meta
        // flag are treated as default language elsewhere, so we count them
        // under the default explicitly.
        $active_codes  = array_keys($this->get_active_languages());
        $default_lang  = $this->default_language;
        $counts = [];
        foreach ($active_codes as $code) { $counts[$code] = 0; }

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core tables; IN built from %s placeholders, values are %-prepared.
        $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Per-language post counts; not cacheable, computed on demand.
            "SELECT COALESCE(pm.meta_value, %s) AS lang, COUNT(*) AS c
               FROM {$wpdb->posts} p
               LEFT JOIN {$wpdb->postmeta} pm
                      ON pm.post_id = p.ID AND pm.meta_key = '_lf_language'
              WHERE p.post_status = 'publish'
                AND p.post_type IN ($placeholders)
              GROUP BY lang",
            array_merge([$default_lang], $types)
        ));
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        foreach ($rows as $row) {
            if (isset($counts[$row->lang])) $counts[$row->lang] = (int) $row->c;
        }
        $empty = [];
        foreach ($counts as $code => $c) {
            if ($c === 0) $empty[] = $code;
        }

        set_transient('lf_empty_languages', $empty, HOUR_IN_SECONDS);
        $this->empty_languages_cache = $empty;
        return $empty;
    }

    /**
 * Drop the cached "empty language" list so the switcher re-evaluates
 * on the next page render. Hooked on save_post / delete_post and
 * from the settings save flow.
 */
    public function clear_empty_languages_cache() {
        delete_transient('lf_empty_languages');
        $this->empty_languages_cache = null;
    }
    
    public function get_language_url($code) {
        // Guard so the home_url / get_permalink calls made *inside* the
        // switcher-URL builder are not re-localized by our own home_url filter
        // (which would otherwise corrupt the default-language link with the
        // currently-active language).
        $prev = $this->in_url_filter;
        $this->in_url_filter = true;
        try {
            return $this->compute_language_url($code);
        } finally {
            $this->in_url_filter = $prev;
        }
    }

    private function compute_language_url($code) {
        global $wp;

        $url_format = get_option('lf_url_format', 'directory');
        // For default language, never include the language segment/param in the URL.
        // This matches WPML behaviour — the default language URL is always "clean".
        $is_default = ($code === $this->default_language);

        // For host-based modes, build the target host base URL
        $home_parsed = wp_parse_url(home_url());
        $site_host = $home_parsed['host'] ?? '';
        $site_port = isset($home_parsed['port']) ? ':' . $home_parsed['port'] : '';
        $scheme = is_ssl() ? 'https' : 'http';
        $home_path_prefix = trim($home_parsed['path'] ?? '', '/');

        // Check if we're on a translated post
        if (is_singular()) {
            $post_id = get_queried_object_id();
            $translation_id = LF_Post_Translation::instance()->get_translation($post_id, $code);
            if ($translation_id && $translation_id !== $post_id) {
                $permalink = get_permalink($translation_id);
                // For host-based modes, rewrite the permalink host to the target language host
                if ($url_format === 'subdomain') {
                    $target_host = $is_default ? $site_host : $code . '.' . $site_host;
                    $permalink = preg_replace('#://[^/]+#', '://' . $target_host . $site_port, $permalink);
                    return $permalink;
                } elseif ($url_format === 'domain') {
                    $domain_map = get_option('lf_domain_map', []);
                    if (!$is_default && isset($domain_map[$code])) {
                        $permalink = preg_replace('#://[^/]+#', '://' . $domain_map[$code], $permalink);
                        return $permalink;
                    }
                    return $permalink;
                }
                if ($url_format === 'parameter') {
                    return $is_default ? $permalink : add_query_arg('lang', $code, $permalink);
                }
                return $permalink;
            }
        }

        // Get current clean path without ANY language segments
        $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
        $raw_path = wp_parse_url($request_uri, PHP_URL_PATH);
        $raw_path = trim($raw_path, '/');

        // Remove home subdirectory prefix (when WordPress is installed in a subdirectory)
        if ($home_path_prefix && strpos($raw_path, $home_path_prefix) === 0) {
            $raw_path = substr($raw_path, strlen($home_path_prefix));
            $raw_path = trim($raw_path, '/');
        }

        // Split path and remove ALL language code segments from ANY position
        $path_parts = $raw_path ? explode('/', $raw_path) : [];
        $clean_parts = [];
        foreach ($path_parts as $part) {
            if (!isset($this->get_active_languages()[$part])) {
                $clean_parts[] = $part;
            }
        }

        $path = implode('/', $clean_parts);

        // Host-based URL modes (subdomain / domain)
        if ($url_format === 'subdomain') {
            $target_host = $is_default ? $site_host : $code . '.' . $site_host;
            $base = $scheme . '://' . $target_host . $site_port;
            if ($home_path_prefix) {
                $base .= '/' . $home_path_prefix;
            }
            return $base . '/' . $path;
        }

        if ($url_format === 'domain') {
            $domain_map = get_option('lf_domain_map', []);
            if (!$is_default && isset($domain_map[$code])) {
                $base = $scheme . '://' . $domain_map[$code];
                if ($home_path_prefix) {
                    $base .= '/' . $home_path_prefix;
                }
                return $base . '/' . $path;
            }
            // Default language or unmapped: use home_url
            return home_url('/' . $path);
        }

        // Default language always gets a clean URL (no lang segment/param)
        if ($is_default) {
            return home_url('/' . $path);
        }

        if ($url_format === 'parameter') {
            return add_query_arg('lang', $code, home_url('/' . $path));
        }

        return home_url('/' . $code . '/' . $path);
    }
    
    /**
 * Localize an arbitrary internal URL to a target language, honouring the
 * configured URL format (parameter / directory / subdomain / domain).
 *
 * Unlike get_language_url — which rewrites the *current* request for the
 * language switcher — this rewrites any given URL. It is used to make
 * nav-menu items that LangForge does not otherwise touch (custom "Home"
 * links and taxonomy archives) carry the active language; post & page
 * permalinks are already handled by LF_Post_Translation::filter_permalink.
 *
 * Idempotent: an existing language segment / ?lang= param is normalized
 * before the target is applied, and external links, anchors and
 * non-navigational schemes (mailto:, tel:) are returned unchanged.
 *
 * @param string $url Absolute or root-relative URL.
 * @param string $code Target language code.
 * @return string
 */
    public function localize_url($url, $code) {
        if ($this->in_url_filter) {
            // Reentrancy: a core home_url/term_link call issued *by* this
            // method (e.g. the internal home_url host lookup) must return the
            // raw value, never re-localize.
            return $url;
        }
        $this->in_url_filter = true;
        try {
            return $this->do_localize_url($url, $code);
        } finally {
            $this->in_url_filter = false;
        }
    }

    private function do_localize_url($url, $code) {
        if (!is_string($url) || $url === '' || $url[0] === '#') {
            return $url;
        }
        if (preg_match('#^(mailto:|tel:|javascript:)#i', $url)) {
            return $url;
        }

        // Drop any stale ?lang= up-front so it can never survive: the language
        // is re-expressed per URL format below (path segment, host, or — for
        // parameter mode only — a fresh lang param).
        if (stripos($url, 'lang=') !== false) {
            $url = remove_query_arg('lang', $url);
        }

        $url_format = get_option('lf_url_format', 'directory');
        $is_default = ($code === $this->default_language);

        $home_parsed = wp_parse_url(home_url());
        $site_host   = $home_parsed['host'] ?? '';
        $home_prefix = trim($home_parsed['path'] ?? '', '/');

        $parts = wp_parse_url($url);
        if ($parts === false) {
            return $url;
        }

        // Internal links only. In host-based modes a link may already sit on a
        // language host/domain — treat those as internal so they normalize.
        if (!empty($parts['host']) && $parts['host'] !== $site_host) {
            $internal_hosts = [];
            foreach (array_keys($this->get_active_languages()) as $lc) {
                $internal_hosts[] = $lc . '.' . $site_host;
            }
            foreach ((array) get_option('lf_domain_map', []) as $dh) {
                if ($dh) { $internal_hosts[] = $dh; }
            }
            if (!in_array($parts['host'], $internal_hosts, true)) {
                return $url;
            }
        }

        // --- Host-based modes: swap the host, keep path/query untouched. ---
        if ($url_format === 'subdomain') {
            $target_host = $is_default ? $site_host : $code . '.' . $site_host;
            $port = isset($parts['port']) ? ':' . $parts['port'] : '';
            if (!empty($parts['host'])) {
                return preg_replace('#://[^/]+#', '://' . $target_host . $port, $url, 1);
            }
            $scheme = is_ssl() ? 'https' : 'http';
            return $scheme . '://' . $target_host . '/' . ltrim($url, '/');
        }
        if ($url_format === 'domain') {
            $domain_map = get_option('lf_domain_map', []);
            if ($is_default || empty($domain_map[$code])) {
                return $url;
            }
            if (!empty($parts['host'])) {
                return preg_replace('#://[^/]+#', '://' . $domain_map[$code], $url, 1);
            }
            $scheme = is_ssl() ? 'https' : 'http';
            return $scheme . '://' . $domain_map[$code] . '/' . ltrim($url, '/');
        }

        // --- Parameter mode: add / replace the lang query arg only. ---
        if ($url_format === 'parameter') {
            return $is_default
                ? remove_query_arg('lang', $url)
                : add_query_arg('lang', $code, $url);
        }

        // --- Directory mode: ensure one /<code>/ segment after the home path. ---
        // The pure path math lives in LF_Url_Helper so it is unit-testable in
        // isolation (this directory rewrite has historically been bug-prone).
        $new_path = LF_Url_Helper::localize_dir_path(
            $parts['path'] ?? '/',
            $home_prefix,
            $this->get_active_languages(),
            $code,
            $is_default
        );

        $rebuilt = '';
        if (!empty($parts['host'])) {
            $scheme = $parts['scheme'] ?? (is_ssl() ? 'https' : 'http');
            $rebuilt = $scheme . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '');
        }
        $rebuilt .= $new_path;
        if (isset($parts['query']))    { $rebuilt .= '?' . $parts['query']; }
        if (isset($parts['fragment'])) { $rebuilt .= '#' . $parts['fragment']; }
        return $rebuilt;
    }

    /**
 * Make the bare site home URL language-aware on the frontend.
 *
 * The Home link and logo in most themes are plain home_url('/') calls that
 * LangForge never otherwise sees, so they always resolve to the
 * default-language root and silently drop a visitor out of their selected
 * language. Inject the active language using the switcher's per-mode rules.
 *
 * Tightly scoped to avoid collateral damage: never in admin / REST / AJAX /
 * cron, never for the REST scheme, only after the main query is parsed
 * (did_action('wp') — when nav is actually rendered), and only for the site
 * root. home_url calls targeting a sub-path (wp-json, feeds, assets) are
 * returned untouched.
 *
 * @param string $url
 * @param string $path
 * @param string|null $orig_scheme
 * @param int|null $blog_id
 * @return string
 */
    public function filter_home_url($url, $path, $orig_scheme = null, $blog_id = null) {
        if ($this->in_url_filter) {
            return $url;
        }
        if ($blog_id !== null && (int) $blog_id !== get_current_blog_id()) {
            return $url;
        }
        if ($orig_scheme === 'rest'
            || is_admin()
            || wp_doing_ajax()
            || (defined('REST_REQUEST') && REST_REQUEST)
            || (defined('DOING_CRON') && DOING_CRON)
            || !did_action('wp')) {
            return $url;
        }
        // Only the site root — leave any specific sub-path alone.
        if (trim((string) $path, '/') !== '') {
            return $url;
        }
        $current = $this->get_current_language();
        if (!$current || $current === $this->default_language) {
            return $url;
        }
        return $this->localize_url($url, $current);
    }

    /**
 * Make taxonomy archive links (category / tag / custom) language-aware.
 *
 * Themes that build navigation from get_category_link / get_term_link
 * — e.g. the bundled recipe theme's category dropdown — otherwise always
 * link to the default-language archive, dropping the visitor's language.
 *
 * @param string $url
 * @param object $term
 * @param string $taxonomy
 * @return string
 */
    public function filter_term_link($url, $term = null, $taxonomy = null) {
        if ($this->in_url_filter || is_admin() || !did_action('wp')) {
            return $url;
        }
        $current = $this->get_current_language();
        if (!$current || $current === $this->default_language) {
            return $url;
        }
        return $this->localize_url($url, $current);
    }

    public function get_url_format() {
        return get_option('lf_url_format', 'directory');
    }

    /**
 * Whether LangForge is currently building a localized URL internally
 * (get_language_url / localize_url). Other URL-rewriting filters check this
 * to avoid interfering with — or recursing into — that work (e.g. the
 * permalink-to-current-language remap must not touch the switcher's own
 * per-language links).
 */
    public function is_building_url() {
        return $this->in_url_filter;
    }

    /**
 * Get translatable post types from settings
 */
    public static function get_translatable_post_types() {
        $saved = get_option('lf_translatable_post_types', []);

        // (2026-05-02): LearnDash CPTs (sfwd-courses / -lessons / -topic /
        // -quiz) are STRUCTURAL on a LearnDash site — translating posts and pages
        // without them leaves the course hierarchy un-translatable, which is
        // never what a LearnDash site owner wants. Even when the user has an
        // explicit saved list (typically because they configured Settings →
        // Translatable post types when first setting LangForge up, before
        // LearnDash was installed or noticed), force-include these so the
        // language metabox + AI Translate actions appear on Course/Lesson/Topic/
        // Quiz edit screens. Tester's complaint: "no LangForge translation block
        // in the sidebar" was caused by sfwd-courses missing from the saved list.
        $is_learndash_active = defined('LEARNDASH_VERSION')
            || class_exists('SFWD_LMS')
            || class_exists('LearnDash_Settings_Section');
        $is_woocommerce_active = defined('WC_VERSION')
            || class_exists('WooCommerce')
            || function_exists('WC');
        // v8 (2026-05-03): adding sfwd-question. Without it, quiz questions
        // are stuck in the source language and the EN quiz translation has no way
        // to surface its question pool. quiz_id is the parent-pointer remap on
        // sfwd-question — see copy_learndash.
        $ld_cpts = ['sfwd-courses', 'sfwd-lessons', 'sfwd-topic', 'sfwd-quiz', 'sfwd-question'];
        $forced_cpts = $ld_cpts;
        if ($is_woocommerce_active) {
            $forced_cpts[] = 'product';
        }

        if (!empty($saved)) {
            if ($is_learndash_active || $is_woocommerce_active) {
                foreach ($forced_cpts as $cpt) {
                    if (($cpt === 'product' || $is_learndash_active) && !in_array($cpt, $saved, true)) {
                        $saved[] = $cpt;
                    }
                }
            }
            return $saved;
        }
        // Fallback: post + page + all public custom post types (excluding 'attachment')
        $types = get_post_types(['public' => true, '_builtin' => false], 'names');
        $merged = array_unique(array_merge(['post', 'page'], array_values($types)));
        // Defensive — if LearnDash registers its CPTs late, the public-types
        // sweep above might miss them on this request. Add explicitly.
        if ($is_learndash_active) {
            foreach ($ld_cpts as $cpt) {
                if (!in_array($cpt, $merged, true)) {
                    $merged[] = $cpt;
                }
            }
        }
        if ($is_woocommerce_active && !in_array('product', $merged, true)) {
            $merged[] = 'product';
        }
        return $merged;
    }
    
    public static function get_available_languages() {
        return [
            // European — West
            'en'    => ['name' => 'English',              'native_name' => 'English',              'flag' => 'gb', 'locale' => 'en_US'],
            'de'    => ['name' => 'German',               'native_name' => 'Deutsch',              'flag' => 'de', 'locale' => 'de_DE'],
            'fr'    => ['name' => 'French',               'native_name' => 'Français',             'flag' => 'fr', 'locale' => 'fr_FR'],
            'es'    => ['name' => 'Spanish',              'native_name' => 'Español',              'flag' => 'es', 'locale' => 'es_ES'],
            'it'    => ['name' => 'Italian',              'native_name' => 'Italiano',             'flag' => 'it', 'locale' => 'it_IT'],
            'pt'    => ['name' => 'Portuguese',           'native_name' => 'Português',            'flag' => 'pt', 'locale' => 'pt_PT'],
            'pt_BR' => ['name' => 'Portuguese (Brazil)',  'native_name' => 'Português (Brasil)',   'flag' => 'br', 'locale' => 'pt_BR'],
            'nl'    => ['name' => 'Dutch',                'native_name' => 'Nederlands',           'flag' => 'nl', 'locale' => 'nl_NL'],
            'ca'    => ['name' => 'Catalan',              'native_name' => 'Català',               'flag' => 'es', 'locale' => 'ca'],
            'eu'    => ['name' => 'Basque',               'native_name' => 'Euskara',              'flag' => 'es', 'locale' => 'eu'],
            'gl'    => ['name' => 'Galician',             'native_name' => 'Galego',               'flag' => 'es', 'locale' => 'gl_ES'],
            'cy'    => ['name' => 'Welsh',                'native_name' => 'Cymraeg',              'flag' => 'gb', 'locale' => 'cy'],
            'ga'    => ['name' => 'Irish',                'native_name' => 'Gaeilge',              'flag' => 'ie', 'locale' => 'ga'],
            // European — Nordic
            'sv'    => ['name' => 'Swedish',              'native_name' => 'Svenska',              'flag' => 'se', 'locale' => 'sv_SE'],
            'da'    => ['name' => 'Danish',               'native_name' => 'Dansk',                'flag' => 'dk', 'locale' => 'da_DK'],
            'no'    => ['name' => 'Norwegian',            'native_name' => 'Norsk',                'flag' => 'no', 'locale' => 'nb_NO'],
            'fi'    => ['name' => 'Finnish',              'native_name' => 'Suomi',                'flag' => 'fi', 'locale' => 'fi'],
            'is'    => ['name' => 'Icelandic',            'native_name' => 'Íslenska',             'flag' => 'is', 'locale' => 'is_IS'],
            // European — East / Slavic
            'ru'    => ['name' => 'Russian',              'native_name' => 'Русский',              'flag' => 'ru', 'locale' => 'ru_RU'],
            'uk'    => ['name' => 'Ukrainian',            'native_name' => 'Українська',           'flag' => 'ua', 'locale' => 'uk'],
            'pl'    => ['name' => 'Polish',               'native_name' => 'Polski',               'flag' => 'pl', 'locale' => 'pl_PL'],
            'cs'    => ['name' => 'Czech',                'native_name' => 'Čeština',              'flag' => 'cz', 'locale' => 'cs_CZ'],
            'sk'    => ['name' => 'Slovak',               'native_name' => 'Slovenčina',           'flag' => 'sk', 'locale' => 'sk_SK'],
            'sl'    => ['name' => 'Slovenian',            'native_name' => 'Slovenščina',          'flag' => 'si', 'locale' => 'sl_SI'],
            'hr'    => ['name' => 'Croatian',             'native_name' => 'Hrvatski',             'flag' => 'hr', 'locale' => 'hr_HR'],
            'bs'    => ['name' => 'Bosnian',              'native_name' => 'Bosanski',             'flag' => 'ba', 'locale' => 'bs_BA'],
            'sr'    => ['name' => 'Serbian',              'native_name' => 'Српски',               'flag' => 'rs', 'locale' => 'sr_RS'],
            'mk'    => ['name' => 'Macedonian',           'native_name' => 'Македонски',           'flag' => 'mk', 'locale' => 'mk_MK'],
            'bg'    => ['name' => 'Bulgarian',            'native_name' => 'Български',            'flag' => 'bg', 'locale' => 'bg_BG'],
            'ro'    => ['name' => 'Romanian',             'native_name' => 'Română',               'flag' => 'ro', 'locale' => 'ro_RO'],
            // European — Other
            'el'    => ['name' => 'Greek',                'native_name' => 'Ελληνικά',             'flag' => 'gr', 'locale' => 'el'],
            'hu'    => ['name' => 'Hungarian',            'native_name' => 'Magyar',               'flag' => 'hu', 'locale' => 'hu_HU'],
            'et'    => ['name' => 'Estonian',             'native_name' => 'Eesti',                'flag' => 'ee', 'locale' => 'et_EE'],
            'lv'    => ['name' => 'Latvian',              'native_name' => 'Latviešu',             'flag' => 'lv', 'locale' => 'lv_LV'],
            'lt'    => ['name' => 'Lithuanian',           'native_name' => 'Lietuvių',             'flag' => 'lt', 'locale' => 'lt_LT'],
            'sq'    => ['name' => 'Albanian',             'native_name' => 'Shqip',                'flag' => 'al', 'locale' => 'sq_AL'],
            'mt'    => ['name' => 'Maltese',              'native_name' => 'Malti',                'flag' => 'mt', 'locale' => 'mt_MT'],
            // Middle East / West Asia
            'ar'    => ['name' => 'Arabic',               'native_name' => 'العربية',              'flag' => 'sa', 'locale' => 'ar',    'rtl' => 1],
            'he'    => ['name' => 'Hebrew',               'native_name' => 'עברית',                'flag' => 'il', 'locale' => 'he_IL', 'rtl' => 1],
            'tr'    => ['name' => 'Turkish',              'native_name' => 'Türkçe',               'flag' => 'tr', 'locale' => 'tr_TR'],
            'fa'    => ['name' => 'Persian',              'native_name' => 'فارسی',                'flag' => 'ir', 'locale' => 'fa_IR', 'rtl' => 1],
            'ku'    => ['name' => 'Kurdish',              'native_name' => 'Kurdî',                'flag' => 'iq', 'locale' => 'ku',    'rtl' => 1],
            'az'    => ['name' => 'Azerbaijani',          'native_name' => 'Azərbaycan',           'flag' => 'az', 'locale' => 'az'],
            'hy'    => ['name' => 'Armenian',             'native_name' => 'Հայերեն',              'flag' => 'am', 'locale' => 'hy_AM'],
            'ka'    => ['name' => 'Georgian',             'native_name' => 'ქართული',              'flag' => 'ge', 'locale' => 'ka_GE'],
            // Central Asia
            'kk'    => ['name' => 'Kazakh',               'native_name' => 'Қазақ',               'flag' => 'kz', 'locale' => 'kk'],
            'uz'    => ['name' => 'Uzbek',                'native_name' => "O'zbek",              'flag' => 'uz', 'locale' => 'uz_UZ'],
            // South Asia
            'hi'    => ['name' => 'Hindi',                'native_name' => 'हिन्दी',               'flag' => 'in', 'locale' => 'hi_IN'],
            'bn'    => ['name' => 'Bengali',              'native_name' => 'বাংলা',                'flag' => 'bd', 'locale' => 'bn_BD'],
            'ur'    => ['name' => 'Urdu',                 'native_name' => 'اردو',                 'flag' => 'pk', 'locale' => 'ur_PK', 'rtl' => 1],
            'pa'    => ['name' => 'Punjabi',              'native_name' => 'ਪੰਜਾਬੀ',               'flag' => 'in', 'locale' => 'pa_IN'],
            'ne'    => ['name' => 'Nepali',               'native_name' => 'नेपाली',               'flag' => 'np', 'locale' => 'ne_NP'],
            'ta'    => ['name' => 'Tamil',                'native_name' => 'தமிழ்',                'flag' => 'lk', 'locale' => 'ta_LK'],
            // East / Southeast Asia
            'zh'    => ['name' => 'Chinese (Simplified)', 'native_name' => '简体中文',              'flag' => 'cn', 'locale' => 'zh_CN'],
            'zh_TW' => ['name' => 'Chinese (Traditional)','native_name' => '繁體中文',             'flag' => 'tw', 'locale' => 'zh_TW'],
            'ja'    => ['name' => 'Japanese',             'native_name' => '日本語',               'flag' => 'jp', 'locale' => 'ja'],
            'ko'    => ['name' => 'Korean',               'native_name' => '한국어',               'flag' => 'kr', 'locale' => 'ko_KR'],
            'vi'    => ['name' => 'Vietnamese',           'native_name' => 'Tiếng Việt',          'flag' => 'vn', 'locale' => 'vi'],
            'th'    => ['name' => 'Thai',                 'native_name' => 'ไทย',                  'flag' => 'th', 'locale' => 'th'],
            'id'    => ['name' => 'Indonesian',           'native_name' => 'Bahasa Indonesia',    'flag' => 'id', 'locale' => 'id_ID'],
            'ms'    => ['name' => 'Malay',                'native_name' => 'Bahasa Melayu',       'flag' => 'my', 'locale' => 'ms_MY'],
            'mn'    => ['name' => 'Mongolian',            'native_name' => 'Монгол',              'flag' => 'mn', 'locale' => 'mn_MN'],
            // Africa
            'sw'    => ['name' => 'Swahili',              'native_name' => 'Kiswahili',           'flag' => 'ke', 'locale' => 'sw_KE'],
            'so'    => ['name' => 'Somali',               'native_name' => 'Soomaali',            'flag' => 'so', 'locale' => 'so_SO'],
            'zu'    => ['name' => 'Zulu',                 'native_name' => 'isiZulu',             'flag' => 'za', 'locale' => 'zu_ZA'],
        ];
    }
    
    // Activation
    public static function activate() {
        global $wpdb;
        
        $charset_collate = $wpdb->get_charset_collate();
        
        // Translations table
        $table_translations = $wpdb->prefix . 'lf_translations';
        $sql_translations = "CREATE TABLE IF NOT EXISTS $table_translations (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            element_id bigint(20) unsigned NOT NULL,
            element_type varchar(60) NOT NULL DEFAULT 'post',
            translation_group bigint(20) unsigned NOT NULL,
            language_code varchar(10) NOT NULL,
            source_language_code varchar(10) DEFAULT NULL,
            PRIMARY KEY (id),
            UNIQUE KEY element_language (element_id, element_type, language_code),
            KEY translation_group (translation_group),
            KEY language_code (language_code)
        ) $charset_collate;";
        
        // Strings table
        $table_strings = $wpdb->prefix . 'lf_strings';
        $sql_strings = "CREATE TABLE IF NOT EXISTS $table_strings (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            string_domain varchar(160) NOT NULL DEFAULT 'default',
            string_name varchar(160) NOT NULL,
            string_value text NOT NULL,
            string_language varchar(10) NOT NULL,
            status tinyint(1) NOT NULL DEFAULT 0,
            PRIMARY KEY (id),
            UNIQUE KEY string_key (string_domain, string_name, string_language),
            KEY string_domain (string_domain),
            KEY string_language (string_language)
        ) $charset_collate;";
        
        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
        dbDelta($sql_translations);
        dbDelta($sql_strings);

        $detected_languages = self::detect_existing_multilingual_languages();
        $default_language = !empty($detected_languages['default'])
            ? $detected_languages['default']
            : 'ru';
        $active_languages = !empty($detected_languages['languages'])
            ? $detected_languages['languages']
            : [
                'ru' => ['name' => 'Русский', 'native_name' => 'Русский', 'flag' => 'ru', 'locale' => 'ru_RU'],
                'en' => ['name' => 'English', 'native_name' => 'English', 'flag' => 'gb', 'locale' => 'en_US'],
            ];

        $has_detected_languages = !empty($detected_languages['languages']);
        $current_active_languages = get_option('lf_active_languages', []);
        $current_default_language = get_option('lf_default_language');
        $current_is_bootstrap_default = is_array($current_active_languages)
            && count($current_active_languages) === 2
            && isset($current_active_languages['ru'], $current_active_languages['en'])
            && in_array($current_default_language, ['ru', 'en', '', false, null], true);

        // Set default options. If a source multilingual plugin is present,
        // prefer its per-site language set over LangForge's bootstrap ru/en
        // defaults; otherwise re-activating on an existing WPML/Polylang site
        // can keep a stale English language that the site never used.
        if (!get_option('lf_default_language') || ($has_detected_languages && $current_is_bootstrap_default)) {
            update_option('lf_default_language', $default_language);
        }

        if (!get_option('lf_active_languages') || ($has_detected_languages && $current_is_bootstrap_default)) {
            update_option('lf_active_languages', $active_languages, false);
        }

        // "Hide default language in URL" is the expected UX — default
        // language serves from `site.com/page`, other languages from
        // `site.com/{lang}/page`. add_option only writes when the key is
        // missing, so users who already tuned this setting keep whatever
        // value they saved; only brand-new installs get the on-by-default.
        add_option('lf_hide_default_language', '1');

        // initialize plain single-language installs only. Existing
        // WPML/Polylang sites already know the language of each post; assigning
        // every missing post to LangForge's default language here creates
        // phantom default-language translations before migration runs.
        if (!$has_detected_languages) {
            self::initialize_existing_content_language();
        }

        // Flush rewrite rules
        flush_rewrite_rules();
    }

    /**
 * Seed LangForge language options from a pre-existing WPML or Polylang site.
 *
 * Activation runs before the admin has a chance to open Migration. If we
 * fall back to the generic ru/en pair here, the dashboard and initial
 * _lf_language bootstrap start from the wrong language set on imported
 * client sites.
 */
    private static function detect_existing_multilingual_languages() {
        $wpml = self::detect_wpml_languages_for_activation();
        if (!empty($wpml['languages'])) {
            return $wpml;
        }

        $pll = self::detect_polylang_languages_for_activation();
        if (!empty($pll['languages'])) {
            return $pll;
        }

        return ['default' => '', 'languages' => []];
    }

    private static function language_option_from_meta($code, $meta = []) {
        $available = self::available_languages_catalog();
        $code = sanitize_key((string) $code);
        $catalog = $available[$code] ?? [];

        $name = '';
        $native = '';
        $locale = '';
        $flag = '';

        if (is_array($meta)) {
            $name = $meta['name'] ?? $meta['english_name'] ?? $meta['translated_name'] ?? '';
            $native = $meta['native_name'] ?? $meta['native'] ?? $meta['display_name'] ?? '';
            $locale = $meta['locale'] ?? $meta['default_locale'] ?? '';
            $flag = $meta['flag'] ?? '';
        } elseif (is_object($meta)) {
            $name = $meta->name ?? $meta->english_name ?? $meta->translated_name ?? '';
            $native = $meta->native_name ?? $meta->native ?? $meta->display_name ?? '';
            $locale = $meta->locale ?? $meta->default_locale ?? '';
            $flag = $meta->flag ?? '';
        }

        $name = $name ?: ($catalog['name'] ?? strtoupper($code));
        $native = $native ?: ($catalog['native_name'] ?? $name);
        $locale = $locale ?: ($catalog['locale'] ?? $code);
        $flag = $flag ?: ($catalog['flag'] ?? substr($code, 0, 2));

        return [
            'name'             => sanitize_text_field($name),
            'native_name'      => sanitize_text_field($native),
            'flag'             => sanitize_key($flag),
            'locale'           => sanitize_text_field($locale),
            'show_in_switcher' => true,
        ];
    }

    private static function detect_wpml_languages_for_activation() {
        global $wpdb;

        $default = '';
        $real_wpml_active = self::is_wpml_active_for_activation();
        if ($real_wpml_active && has_filter('wpml_default_language')) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility
            $filtered_default = apply_filters('wpml_default_language', null); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility
            if (is_string($filtered_default) && $filtered_default !== '') {
                $default = sanitize_key($filtered_default);
            }
        }

        $active = [];
        if ($real_wpml_active && has_filter('wpml_active_languages')) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility
            $filtered = apply_filters('wpml_active_languages', null); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility
            if (is_array($filtered)) {
                foreach ($filtered as $key => $lang) {
                    $code = is_array($lang)
                        ? ($lang['language_code'] ?? $lang['code'] ?? $key)
                        : (is_object($lang) ? ($lang->language_code ?? $lang->code ?? $key) : $key);
                    $code = sanitize_key((string) $code);
                    if ($code) $active[$code] = $lang;
                }
            }
        }

        $settings = get_option('icl_sitepress_settings', []);
        if (is_string($settings)) $settings = forge_safe_unserialize($settings);
        if (!$default && is_array($settings) && !empty($settings['default_language'])) {
            $default = sanitize_key($settings['default_language']);
        }

        if (empty($active) && is_array($settings) && !empty($settings['active_languages']) && is_array($settings['active_languages'])) {
            foreach ($settings['active_languages'] as $code) {
                $code = sanitize_key((string) $code);
                if ($code) $active[$code] = [];
            }
        }

        $table = $wpdb->prefix . 'icl_languages';
        $wpml_meta = [];
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- WPML table existence check
            $rows = $wpdb->get_results("SELECT code, english_name, default_locale FROM {$table}"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time activation read
            foreach ($rows as $row) {
                $wpml_meta[sanitize_key($row->code)] = [
                    'name' => $row->english_name,
                    'native_name' => $row->english_name,
                    'locale' => $row->default_locale,
                ];
            }
            if (empty($active)) {
                $codes = $wpdb->get_col("SELECT code FROM {$table} WHERE active = 1"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time activation read
                foreach ($codes as $code) {
                    $code = sanitize_key((string) $code);
                    if ($code) $active[$code] = [];
                }
            }
        }

        $languages = [];
        foreach ($active as $code => $meta) {
            $merged = is_array($meta) ? array_merge($wpml_meta[$code] ?? [], $meta) : ($wpml_meta[$code] ?? $meta);
            $languages[$code] = self::language_option_from_meta($code, $merged);
        }

        return ['default' => $default, 'languages' => $languages];
    }

    private static function is_wpml_active_for_activation() {
        $active_plugins = get_option('active_plugins', []);
        if (!is_array($active_plugins)) $active_plugins = [];
        if (in_array('sitepress-multilingual-cms/sitepress.php', $active_plugins, true)) {
            return true;
        }

        if (is_multisite()) {
            $network_plugins = get_site_option('active_sitewide_plugins', []);
            return is_array($network_plugins)
                && isset($network_plugins['sitepress-multilingual-cms/sitepress.php']);
        }

        return false;
    }

    private static function detect_polylang_languages_for_activation() {
        global $wpdb;

        $options = get_option('polylang', []);
        if (is_string($options)) $options = forge_safe_unserialize($options);
        $default = is_array($options) && !empty($options['default_lang'])
            ? sanitize_key($options['default_lang'])
            : '';

        $terms = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time activation read
            "SELECT t.term_id, t.slug, t.name, tt.description
             FROM {$wpdb->terms} t
             JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id
             WHERE tt.taxonomy = 'language'"
        );

        $languages = [];
        foreach ($terms as $term) {
            $meta = forge_safe_unserialize($term->description);
            if (!is_array($meta)) {
                $meta = get_term_meta($term->term_id, '_pll_language', true);
                if (is_string($meta)) $meta = forge_safe_unserialize($meta);
            }
            $code = sanitize_key($term->slug);
            $languages[$code] = self::language_option_from_meta($code, [
                'name' => $term->name,
                'native_name' => $term->name,
                'locale' => is_array($meta) ? ($meta['locale'] ?? $code) : $code,
                'flag' => is_array($meta) ? ($meta['flag_code'] ?? ($meta['flag'] ?? $code)) : $code,
            ]);
        }

        if (!$default && !empty($languages)) {
            $default = (string) array_key_first($languages);
        }

        return ['default' => $default, 'languages' => $languages];
    }

    private static function available_languages_catalog() {
        return self::get_available_languages();
    }

    /**
 * Assign default language to all existing posts/pages missing _lf_language.
 * Runs on activation and can be called manually for repair.
 */
    public static function initialize_existing_content_language() {
        global $wpdb;

        $default_lang = get_option('lf_default_language', 'ru');
        $translatable = self::get_translatable_post_types();
        if (empty($translatable)) return;

        $type_in = implode(',', array_fill(0, count($translatable), '%s'));

        // Find all published/draft posts of translatable types that have NO _lf_language
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Core tables; IN built from %s placeholders, values are %-prepared.
        $posts_without_lang = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill scan; not cacheable.
            "SELECT p.ID, p.post_type 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 IN ($type_in) AND p.post_status IN ('publish', 'draft')
            AND pm.meta_value IS NULL
            ORDER BY p.ID ASC",
            ...$translatable
        ));
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

        if (empty($posts_without_lang)) return;

        $table = $wpdb->prefix . 'lf_translations';
        $max_group = (int) $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.

        foreach ($posts_without_lang as $post) {
            update_post_meta($post->ID, '_lf_language', $default_lang);

            // Check if already in translations table
            $exists = $wpdb->get_var($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 id FROM $table WHERE element_id = %d AND element_type = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
                $post->ID, $post->post_type
            ));

            if (!$exists) {
                $max_group++;
                $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->post_type,
                    'translation_group' => $max_group,
                    'language_code' => $default_lang,
                    'source_language_code' => null,
                ]);
            }
        }
    }
}
