<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Which language the interface speaks.
 *
 * BY DEFAULT: the one WordPress is already speaking. `determine_locale()` is the right question and
 * not `get_locale()`, because in the admin the two differ whenever an editor has set a language on
 * their own profile — and the plugin's interface is an admin interface, so it has to follow the
 * PERSON, not the site. A site in Italian with an English-speaking editor gets an English Studio
 * for that editor alone, which is what both of them expect.
 *
 * AND AN OVERRIDE, on the Settings page, because "the interface language" and "the site language"
 * are not always the same wish. The common case is a site being built in Italian for an Italian
 * client by someone who would rather read the tool in English (or the other way round): without
 * this they would have to change the language of the whole WordPress, which changes the site too.
 *
 * WHAT THE OVERRIDE DOES NOT TOUCH is everything else on the screen: WordPress's own menus, the
 * other plugins, the content, the dates. It reaches the `aipb` text domain and nothing else, which
 * is why it is not a call to `switch_to_locale()`. A plugin that switches the global locale to suit
 * itself changes number formats, date formats and every neighbouring plugin's strings as a side
 * effect, and the person who ticked «show me Aura in English» asked for none of that.
 *
 * THE TWO HALVES ARE LOADED DIFFERENTLY, and both need saying:
 *  - PHP reads `languages/aipb-<locale>.mo`. See {@see load_textdomain()} below for why overriding
 *    is not simply the same call with another locale.
 *  - The JS reads `languages/aipb-<locale>-<handle>.json`, through `wp_set_script_translations()`.
 *    That path is built from `determine_locale()` deep inside core with no filter of its own, so
 *    the interception happens one step later, on `load_script_translation_file`, where core hands
 *    over the filename it was about to read.
 *
 * ENGLISH IS THE SOURCE and therefore has no catalogue: choosing it simply means no file is found,
 * and gettext falls back to the string written in the code. That is not a failure mode, it is how
 * the mechanism is meant to work — which is why `en_US` is offered in the list even though there is
 * no `aipb-en_US.mo` on disk and never will be.
 */
class AIPB_I18n {

	/** The option. Empty string = follow WordPress, which is the default and the sane one. */
	const OPT_LOCALE = 'aipb_locale';

	/** The language the interface is WRITTEN in: it needs no catalogue. */
	const SOURCE_LOCALE = 'en_US';

	/** Resolved once per request: `available()` hits the disk and `locale()` is asked often. */
	protected static $available = null;
	protected static $locale    = null;

	public static function init() {
		// Registered on `plugins_loaded` (see aipb_init) so that it is in place well before the
		// `init` that loads the catalogues.
		add_filter( 'load_script_translation_file', array( __CLASS__, 'filter_script_file' ), 10, 3 );

		add_action( 'init', array( __CLASS__, 'load_textdomain' ) );
	}

	/* ------------------------------- The choice ------------------------------- */

	/**
	 * The locale the plugin's interface should use.
	 *
	 * @return string
	 */
	public static function locale() {
		if ( null !== self::$locale ) {
			return self::$locale;
		}

		$wordpress = function_exists( 'determine_locale' ) ? (string) determine_locale() : (string) get_locale();
		$chosen    = self::stored();

		// A locale saved when a catalogue existed and gone since — a language removed from a build —
		// must not leave the interface pointing at a file that is not there. Falling back to
		// WordPress's own choice is the behaviour of somebody who never set the option at all.
		if ( '' === $chosen || ! isset( self::available()[ $chosen ] ) ) {
			$chosen = $wordpress;
		}

		/**
		 * The locale used for Aura's own interface.
		 *
		 * @param string $chosen    The locale about to be used.
		 * @param string $wordpress What WordPress would have used.
		 */
		self::$locale = (string) apply_filters( 'aipb_locale', $chosen, $wordpress );

		return self::$locale;
	}

	/** The raw option, with no fallback applied: '' means «follow WordPress». */
	public static function stored() {
		return trim( (string) get_option( self::OPT_LOCALE, '' ) );
	}

	/** True when the plugin is deliberately speaking a different language from the rest of the admin. */
	public static function overridden() {
		$wordpress = function_exists( 'determine_locale' ) ? (string) determine_locale() : (string) get_locale();

		return self::locale() !== $wordpress;
	}

	/**
	 * The languages this copy of the plugin can actually speak.
	 *
	 * Read from the DISK — one entry per `languages/aipb-<locale>.mo` — and not from a list written
	 * out somewhere, because the list written out somewhere is the one that goes on offering a
	 * language after the file that made it possible stopped being shipped. The .mo and not the .po:
	 * the .mo is the file WordPress reads, and a language whose .mo is missing would appear in the
	 * menu and then do nothing.
	 *
	 * @return array<string,string> locale => the language's name in that language
	 */
	public static function available() {
		if ( null !== self::$available ) {
			return self::$available;
		}

		$found = array( self::SOURCE_LOCALE => self::language_name( self::SOURCE_LOCALE ) );

		foreach ( glob( AIPB_PATH . 'languages/aipb-*.mo' ) ?: array() as $file ) {
			$locale = substr( basename( $file, '.mo' ), 5 ); // strip 'aipb-'
			if ( '' === $locale ) {
				continue;
			}
			$found[ $locale ] = self::language_name( $locale );
		}

		/**
		 * The languages offered in the Settings menu.
		 *
		 * @param array<string,string> $found locale => label.
		 */
		self::$available = (array) apply_filters( 'aipb_available_locales', $found );

		return self::$available;
	}

	/**
	 * A language's name, written in that language.
	 *
	 * The map is deliberately short: it holds what is shipped, and anything else falls back to the
	 * locale code. A code in a menu is ugly but it is TRUE, and it is better than a wrong name — the
	 * alternative, `wp_get_available_translations()`, asks wordpress.org over the network and
	 * answers about the translations of WordPress, not about ours.
	 */
	protected static function language_name( $locale ) {
		$names = apply_filters( 'aipb_language_names', array(
			'en_US' => 'English',
			'it_IT' => 'Italiano',
			'de_DE' => 'Deutsch',
			'fr_FR' => 'Français',
			'es_ES' => 'Español',
		) );

		return isset( $names[ $locale ] ) ? (string) $names[ $locale ] : (string) $locale;
	}

	/* -------------------------------- The wiring ------------------------------- */

	/**
	 * Loads the PHP catalogue.
	 *
	 * On `init` and not earlier: WordPress 6.7 and later report a domain loaded before then, and it
	 * is right to — nothing of ours prints a string before `init`.
	 *
	 * FOLLOWING WORDPRESS is the ordinary road and takes the ordinary function, which looks in
	 * `wp-content/languages/plugins` before our own folder — the place a site owner's own
	 * translation lives.
	 *
	 * OVERRIDING IS NOT THE SAME FUNCTION WITH A DIFFERENT LOCALE, and this is the part that is
	 * easy to get wrong: since WordPress 6.5 translations are filed under the locale they were
	 * loaded for — `[locale][domain]` — and every lookup reads the CURRENT locale, the one
	 * `determine_locale()` returns. Loading `aipb-it_IT.mo` as it_IT on a site running in English
	 * therefore files it in a drawer nothing ever opens: the strings are in memory, correct, and
	 * invisible. Worse, `load_textdomain()` sets the current locale to whatever it was handed, so
	 * doing it that way briefly makes the WHOLE admin think it is Italian until the next plugin
	 * loads its own domain and sets it back.
	 *
	 * So the file is chosen by OUR locale and filed under the SITE's. The bucket is per text
	 * domain, so nothing but `aipb` is touched, and plural forms still come out right because the
	 * rule is read from the file's own header and not from the locale it is filed under.
	 *
	 * THE UNLOAD IS NOT DECORATION. Something may already have pulled the domain in just-in-time,
	 * in the site's language — core's just-in-time path knows nothing about our option. Dropping
	 * what it loaded is what makes the load below the one that counts, and for a plugin overridden
	 * to English it is the entire job: there is no catalogue to put in its place, because English
	 * is what the sources already say.
	 */
	public static function load_textdomain() {
		$wanted  = self::locale();
		$current = function_exists( 'determine_locale' ) ? (string) determine_locale() : (string) get_locale();

		if ( $wanted === $current ) {
			load_plugin_textdomain( 'aipb', false, dirname( plugin_basename( AIPB_PATH . 'ai-page-builder.php' ) ) . '/languages' );

			return;
		}

		unload_textdomain( 'aipb' );

		if ( self::SOURCE_LOCALE === $wanted ) {
			return;
		}

		// The site owner's own catalogue first, ours second: the same order `load_plugin_textdomain()`
		// uses, so that overriding the language does not also override somebody's corrections.
		$candidates = array(
			WP_LANG_DIR . '/plugins/aipb-' . $wanted . '.mo',
			AIPB_PATH . 'languages/aipb-' . $wanted . '.mo',
		);

		foreach ( $candidates as $mofile ) {
			if ( is_readable( $mofile ) && load_textdomain( 'aipb', $mofile, $current ) ) {
				return;
			}
		}
	}

	/**
	 * JS: which .json `wp_set_script_translations()` goes looking for.
	 *
	 * IT ONLY SPEAKS UP WHEN THE LANGUAGE IS OVERRIDDEN, and that is the important half. Core does
	 * not look in one place: it tries the plugin's own folder by handle, then by the md5 of the
	 * source path, then `wp-content/languages/plugins` — which is where a translation somebody
	 * added themselves lives, and where wordpress.org would deliver one. Answering on every call
	 * would replace that whole chain with a single path of ours and quietly break a catalogue the
	 * site owner put there by hand, for no gain: when we are following WordPress, the filename core
	 * has already built is the one we would have built.
	 *
	 * When the language IS overridden the answer is ours whether the file exists or not. A missing
	 * file means English, which is the language the JS is written in; falling back to the path core
	 * proposed would mean the site's language, which is exactly what the person overrode.
	 *
	 * The path is rebuilt rather than patched. A `str_replace` of one locale for another looks
	 * equivalent and is not: the locale code can occur inside the plugin's own directory name on
	 * somebody's install, and the day it does the replacement silently mangles the path instead of
	 * the filename.
	 *
	 * @param string|false $file
	 * @param string       $handle
	 * @param string       $domain
	 * @return string|false
	 */
	public static function filter_script_file( $file, $handle, $domain ) {
		if ( 'aipb' !== $domain || ! self::overridden() ) {
			return $file;
		}

		$locale = self::locale();
		if ( self::SOURCE_LOCALE === $locale ) {
			return false; // no catalogue, and none wanted: the JS falls back to its own strings.
		}

		return AIPB_PATH . 'languages/aipb-' . $locale . '-' . $handle . '.json';
	}

	/* ------------------------------ The setting ------------------------------- */

	/**
	 * Keeps only a locale this copy can actually speak; anything else means «follow WordPress».
	 *
	 * @param mixed $value
	 * @return string
	 */
	public static function sanitize( $value ) {
		$value = is_string( $value ) ? trim( $value ) : '';

		return isset( self::available()[ $value ] ) ? $value : '';
	}

	/** The option changed: the resolved values from this request are about to be wrong. */
	public static function flush() {
		self::$locale    = null;
		self::$available = null;
	}
}
