<?php

/**
 * Bundled translations as a fallback layer (PeepSo/PeepSo#8090, PeepSo/PeepSo#8076).
 *
 * WordPress resolves ONE directory per textdomain+locale (wp-content/languages/plugins first, the
 * plugin's own language/ dir last) and never merges: a partial user override hides every bundled
 * string, and a locale without an exact catalog (de_AT, es_MX, de_DE_formal...) renders English even
 * though a usable base catalog ships. This loader intercepts every load of a PeepSo-fleet textdomain
 * and stacks the catalogs so the user's own translation always wins per-string:
 *
 *   user(exact locale)  >  bundled(exact)  >  user(base locale)  >  bundled(base)
 *
 * On the WP 6.5+ translation controller the FIRST-loaded file wins per string and later files only
 * fill gaps; on the legacy pomo pipeline MO::merge_with keeps earlier entries the same way — so the
 * one load order gives the same precedence everywhere and no WP version gate is needed.
 *
 * Where a domain's bundled catalogs live is NEVER guessed from directory names (users rename plugin
 * folders). Two truthful sources, in order:
 *   1. The 'peepso_textdomains' filter — plugins self-report `domain => absolute language dir`,
 *      derived from their own __FILE__ (same pattern as 'peepso_license_config').
 *   2. For fleet domains that don't self-report yet: the custom path their own
 *      load_plugin_textdomain() call registered in WP_Textdomain_Registry — also __FILE__-derived,
 *      read via reflection (no public getter) and dropped silently if core ever changes shape.
 *
 * The regional base map follows ListoWP's fallback_base: an explicit variant->base table, no
 * strip-to-bare rule, because WordPress never sets a region-less 'es' (Spanish is es_ES/es_MX/es_CO/…;
 * bare codes exist only for languages WP itself ships bare, like 'ar').
 */
class PeepSo3_Bundled_Translations
{
    /**
     * Every textdomain the fleet has ever shipped catalogs under. Domains are stable API (baked into
     * .mo filenames forever), unlike directory names. Used only to decide WHICH domains this loader
     * may handle; the bundled location itself always comes from a live signal (see bundled_dir()).
     */
    const FLEET_DOMAINS = array(
        'peepso-core', 'msgso', 'peepso-email-digest', 'peepsofileuploads', 'friendso', 'groupso',
        'vidso', 'pageso', 'picso', 'peepso-stories', 'peepsolimitusers', 'peepso-push',
        'peepso-advanced-ads', 'peepso-alsp', 'peepso-app', 'peepso-awecommerce', 'badgeos-peepso',
        'peepso-edd', 'peepsogivewp', 'peepsoideapush', 'peepsolearndash', 'peepsomasterstudy',
        'peepsocreds', 'peepso-pmp', 'social-login', 'peepsotutorlms', 'peepsowc',
        'peepso-wpadverts', 'peepso-wpem', 'peepso-wpjm',
    );

    private static $instance;

    /** @var bool Re-entrancy lock — the override filter fires again for our own inner load_textdomain() calls. */
    private $busy = FALSE;

    /** @var array domain => resolved bundled language dir ('' = unresolved). Negative results are only cached after init. */
    private $dirs = array();

    /** @var array "domain|locale" keys already layered — also keeps the init sweep idempotent. */
    private $handled = array();

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

        return self::$instance;
    }

    private function __construct()
    {
        add_filter('override_load_textdomain', array($this, 'override_load_textdomain'), 999, 4);

        // A domain JIT-loaded before this class existed (an early lookup — WP warns since 6.7 but still
        // loads) never re-enters the filter, so its gaps would stay unfilled. Sweep once after init.
        add_action('init', array($this, 'layer_already_loaded'), 20);
    }

    /**
     * Resolve where $domain's bundled catalogs live. Self-reported path first, then the custom path
     * the plugin itself registered via load_plugin_textdomain(). '' when unknown — the domain is then
     * left entirely to stock behavior.
     */
    private function bundled_dir($domain)
    {
        if (isset($this->dirs[$domain])) {
            return $this->dirs[$domain];
        }

        $registered = apply_filters('peepso_textdomains', array(
            'peepso-core' => untrailingslashit(PeepSo::absolute_textdomain_path()),
        ));

        $dir = '';
        if (!empty($registered[$domain])) {
            $dir = untrailingslashit($registered[$domain]);
        } elseif (in_array($domain, self::FLEET_DOMAINS, TRUE)) {
            $dir = $this->registry_custom_path($domain);
        }

        // Children register their custom paths on init; a miss before that may become a hit later.
        if ('' !== $dir || did_action('init')) {
            $this->dirs[$domain] = $dir;
        }

        return $dir;
    }

    /**
     * The path load_plugin_textdomain() registered for $domain — WP_Textdomain_Registry keeps it
     * private with no getter. Reflection, guarded: any failure means '' and stock behavior.
     */
    private function registry_custom_path($domain)
    {
        global $wp_textdomain_registry;

        if (!is_object($wp_textdomain_registry) || !property_exists($wp_textdomain_registry, 'custom_paths')) {
            return '';
        }

        try {
            $prop = new ReflectionProperty($wp_textdomain_registry, 'custom_paths');
            $prop->setAccessible(TRUE);
            $paths = $prop->getValue($wp_textdomain_registry);
        } catch (ReflectionException $e) {
            return '';
        }

        return (is_array($paths) && !empty($paths[$domain])) ? untrailingslashit($paths[$domain]) : '';
    }

    /**
     * The domains this loader may need to sweep: the fleet list plus anything self-reported.
     */
    private function known_domains()
    {
        $registered = apply_filters('peepso_textdomains', array(
            'peepso-core' => untrailingslashit(PeepSo::absolute_textdomain_path()),
        ));

        return array_unique(array_merge(self::FLEET_DOMAINS, array_keys($registered)));
    }

    /**
     * Map a regional variant to the base catalog PeepSo actually ships; '' when the locale is itself
     * the base (or has no shipped base). pt maps to pt_BR (the bundled Portuguese); zh is deliberately
     * absent — Simplified and Traditional don't substitute for each other.
     */
    public static function fallback_base($locale)
    {
        $map = apply_filters('peepso_locale_fallback_map', array(
            'es_' => 'es_ES',
            'de_' => 'de_DE',
            'fr_' => 'fr_FR',
            'nl_' => 'nl_NL',
            'pt_' => 'pt_BR',
            'ar_' => 'ar',
            'it_' => 'it_IT',
            'sv_' => 'sv_SE',
        ));

        foreach ($map as $prefix => $base) {
            if ($base != $locale && 0 === strpos($locale, $prefix)) {
                return $base;
            }
        }

        return '';
    }

    public function override_load_textdomain($override, $domain, $mofile, $locale = NULL)
    {
        if ($this->busy || $override) {
            return $override;
        }

        $bundled_dir = $this->bundled_dir($domain);
        if ('' === $bundled_dir) {
            return $override;
        }

        if (!is_string($locale) || 0 == strlen($locale)) {
            $locale = function_exists('determine_locale') ? determine_locale() : get_locale();
        }

        // en_US is the source language; other en_* fall back to it naturally by translating nothing.
        if ('en' == $locale || 0 === strpos($locale, 'en_')) {
            return $override;
        }

        $base = self::fallback_base($locale);

        // Layer 0 is whatever WordPress resolved (the user's file when one exists) and always goes
        // through load_textdomain(), so third-party filters (Loco Translate's custom-directory mapping,
        // the .l10n.php preference) still apply to it — even when the file itself doesn't exist.
        $layers = array($mofile);

        $bundled_exact = $bundled_dir . DIRECTORY_SEPARATOR . $domain . '-' . $locale . '.mo';
        if (wp_normalize_path($bundled_exact) != wp_normalize_path($mofile)) {
            $layers[] = $bundled_exact;
        }

        if (strlen($base)) {
            $layers[] = WP_LANG_DIR . '/plugins/' . $domain . '-' . $base . '.mo';
            $layers[] = $bundled_dir . DIRECTORY_SEPARATOR . $domain . '-' . $base . '.mo';
        }

        $loaded_requested = FALSE;
        $loaded_any = FALSE;

        $this->busy = TRUE;
        try {
            foreach ($layers as $i => $file) {
                if ($i > 0 && !file_exists($file)) {
                    continue;
                }
                if (load_textdomain($domain, $file, $locale)) {
                    $loaded_any = TRUE;
                    if (0 === $i) {
                        $loaded_requested = TRUE;
                    }
                }
            }
        } finally {
            $this->busy = FALSE;
        }

        $this->handled[$domain . '|' . $locale] = TRUE;

        // Every successful inner load re-pinned the textdomain registry to its own directory (the last
        // one wins). Restore the pin to the directory WordPress originally resolved, so a later reload
        // of this domain+locale re-enters here with the same $mofile.
        if ($loaded_requested && isset($GLOBALS['wp_textdomain_registry'])) {
            $GLOBALS['wp_textdomain_registry']->set($domain, $locale, dirname($mofile));
        }

        return $loaded_any ? TRUE : $override;
    }

    /**
     * Gap-fill for domains that loaded before our filter was registered: append the bundled layers.
     * They land BELOW whatever loaded first, so existing translations keep winning per-string.
     */
    public function layer_already_loaded()
    {
        $locale = function_exists('determine_locale') ? determine_locale() : get_locale();
        if ('en' == $locale || 0 === strpos($locale, 'en_')) {
            return;
        }

        $base = self::fallback_base($locale);

        foreach ($this->known_domains() as $domain) {
            if (isset($this->handled[$domain . '|' . $locale]) || !is_textdomain_loaded($domain)) {
                continue;
            }

            $dir = $this->bundled_dir($domain);
            if ('' === $dir) {
                continue;
            }

            $this->handled[$domain . '|' . $locale] = TRUE;

            $files = array($dir . DIRECTORY_SEPARATOR . $domain . '-' . $locale . '.mo');
            if (strlen($base)) {
                $files[] = $dir . DIRECTORY_SEPARATOR . $domain . '-' . $base . '.mo';
            }

            $this->busy = TRUE;
            try {
                foreach ($files as $file) {
                    if (file_exists($file)) {
                        load_textdomain($domain, $file, $locale);
                    }
                }
            } finally {
                $this->busy = FALSE;
            }
        }
    }
}

PeepSo3_Bundled_Translations::get_instance();
