<?php
/*
Многоязычность панели. Добавлено в сборке REDL.
Поддерживается и развивается с помощью REDL.IO — Хостинг с искусственным интеллектом.

Как работает
------------
Панель изначально написана с русским текстом прямо в шаблонах (около 1500 строк
в 200 файлах). Переписывать их все на вызовы t() — большой риск что-нибудь сломать,
поэтому перевод делается на уровне ГОТОВОГО ОТВЕТА: index.php оборачивает вывод в
ob_start(), и перед отправкой браузеру строки заменяются по словарю.

Плюсы такого подхода:
  * работает сразу во всей панели, включая админку и письма;
  * ничего не может «исчезнуть» — если фразы нет в словаре, остаётся русский текст;
  * добавить язык = положить один файл в application/lang/.

Порядок определения языка:
  1. ?lang=en   — явный выбор пользователя, запоминается в cookie на год
  2. cookie lang — прошлый выбор
  3. Accept-Language браузера — автоопределение
  4. 'lang' из application/config.php, иначе русский
*/

class Lang {

	/** Языки, для которых есть словарь. */
	const AVAILABLE = array('ru', 'en');

	/** Языки, для которых уместнее оставить русский интерфейс. */
	private static $ruFamily = array('ru','uk','be','kk','ky','uz','az','hy','ka','tg','tk','mo','md');

	private $code = 'ru';
	private $map  = array();
	private $patterns = null;
	private static $instance = null;

	public function __construct($registry = null) {
		$this->code = $this->detect($registry);

		// Русский — исходный язык шаблонов, словарь ему не нужен.
		if ($this->code !== 'ru') {
			$file = APPLICATION_DIR . 'lang/' . $this->code . '.php';
			if (is_readable($file)) {
				$map = include($file);
				if (is_array($map)) {
					// пустые переводы отбрасываем, иначе текст пропал бы со страницы
					foreach ($map as $from => $to) {
						if (is_string($from) && is_string($to) && $from !== '' && $to !== '') {
							$this->map[$from] = $to;
						}
					}
				}
			}
		}
		self::$instance = $this;
	}

	public static function instance() {
		return self::$instance;
	}

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

	public function available() {
		return self::AVAILABLE;
	}

	/** Человеческое имя языка для переключателя. */
	public function name($code) {
		$names = array('ru' => 'Русский', 'en' => 'English');
		return isset($names[$code]) ? $names[$code] : strtoupper($code);
	}

	/** Адрес текущей страницы с другим языком — для ссылок переключателя. */
	public function switchUrl($code) {
		$uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
		$parts = explode('#', $uri, 2);
		$uri = $parts[0];
		$parts = explode('?', $uri, 2);
		$path = $parts[0];
		$query = array();
		if (isset($parts[1]) && $parts[1] !== '') {
			parse_str($parts[1], $query);
		}
		$query['lang'] = $code;
		return $path . '?' . http_build_query($query);
	}

	private function detect($registry) {
		// 1. явный выбор
		if (isset($_GET['lang']) && in_array($_GET['lang'], self::AVAILABLE, true)) {
			$code = $_GET['lang'];
			if (!headers_sent()) {
				setcookie('lang', $code, time() + 31536000, '/');
			}
			$_COOKIE['lang'] = $code;
			return $code;
		}

		// 2. прошлый выбор
		if (isset($_COOKIE['lang']) && in_array($_COOKIE['lang'], self::AVAILABLE, true)) {
			return $_COOKIE['lang'];
		}

		// 3. автоопределение по браузеру
		if (!empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
			foreach (self::parseAccept($_SERVER['HTTP_ACCEPT_LANGUAGE']) as $tag) {
				$short = strtolower(substr($tag, 0, 2));
				if (in_array($short, self::$ruFamily, true)) {
					return 'ru';
				}
				if (in_array($short, self::AVAILABLE, true)) {
					return $short;
				}
			}
			// браузер просит язык, которого у нас нет и который не русскоязычный
			return 'en';
		}

		// 4. значение по умолчанию из конфигурации
		if ($registry && isset($registry->config)) {
			$default = $registry->config->lang;
			if ($default && in_array($default, self::AVAILABLE, true)) {
				return $default;
			}
		}
		return 'ru';
	}

	/** Разбор Accept-Language в список тегов по убыванию веса q. */
	private static function parseAccept($header) {
		$tags = array();
		foreach (explode(',', $header) as $chunk) {
			$chunk = trim($chunk);
			if ($chunk === '') { continue; }
			$q = 1.0;
			if (strpos($chunk, ';') !== false) {
				list($chunk, $params) = explode(';', $chunk, 2);
				if (preg_match('~q\s*=\s*([0-9.]+)~i', $params, $m)) {
					$q = (float)$m[1];
				}
				$chunk = trim($chunk);
			}
			if ($chunk !== '' && $chunk !== '*') {
				$tags[] = array($chunk, $q);
			}
		}
		usort($tags, function($a, $b) {
			if ($a[1] == $b[1]) { return 0; }
			return ($a[1] < $b[1]) ? 1 : -1;
		});
		$out = array();
		foreach ($tags as $t) { $out[] = $t[0]; }
		return $out;
	}

	/** Перевод одной строки. Нет в словаре — возвращаем как есть. */
	public function get($text) {
		return isset($this->map[$text]) ? $this->map[$text] : $text;
	}

	/**
	 * Коллбэк для ob_start: переводит весь ответ перед отправкой.
	 * Ответы AJAX обрабатываются отдельно: json_encode экранирует кириллицу
	 * в \uXXXX, и словарь по сырому UTF-8 туда бы не попал.
	 */
	public function apply($buffer) {
		if ($this->code === 'ru' || !$this->map || !is_string($buffer) || $buffer === '') {
			return $buffer;
		}

		$head = ltrim($buffer);
		if ($head !== '' && ($head[0] === '{' || $head[0] === '[')) {
			$data = json_decode($head, true);
			if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
				$json = json_encode($this->walk($data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
				if ($json !== false) {
					return $json;
				}
			}
		}

		return $this->replace($buffer);
	}

	private function walk($value) {
		if (is_array($value)) {
			$out = array();
			foreach ($value as $k => $v) { $out[$k] = $this->walk($v); }
			return $out;
		}
		return is_string($value) ? $this->replace($value) : $value;
	}

	/**
	 * Замена по словарю ТОЛЬКО на границах слов.
	 *
	 * Простой strtr() здесь не годится: короткий ключ подменяется внутри более
	 * длинного слова и получается каша из двух языков — 'Мод' превращал
	 * «Модуль» в «Modуль», а 'день' делал из «деньги» «dayги». Поэтому вокруг
	 * каждого ключа требуем отсутствие буквы. Цифра слева разрешена намеренно:
	 * иначе «0руб.» осталось бы без перевода.
	 *
	 * Ключи отсортированы от длинных к коротким, чтобы «Сменить пароль»
	 * срабатывало раньше, чем «Пароль».
	 */
	private function replace($text) {
		if ($this->patterns === null) {
			$this->buildPatterns();
		}
		foreach ($this->patterns as $pattern) {
			$out = preg_replace_callback($pattern, array($this, 'hit'), $text);
			if ($out !== null) { $text = $out; }
		}
		return $text;
	}

	private function hit($m) {
		return isset($this->map[$m[1]]) ? $this->map[$m[1]] : $m[1];
	}

	private function buildPatterns() {
		$keys = array_keys($this->map);
		// длинные первыми: иначе короткий ключ перехватит часть длинной фразы
		usort($keys, function($a, $b) {
			$la = mb_strlen($a, 'UTF-8'); $lb = mb_strlen($b, 'UTF-8');
			if ($la === $lb) { return strcmp($a, $b); }
			return ($la < $lb) ? 1 : -1;
		});
		$this->patterns = array();
		// режем на группы, чтобы не упереться в предел размера шаблона PCRE
		foreach (array_chunk($keys, 150) as $chunk) {
			$quoted = array();
			foreach ($chunk as $k) { $quoted[] = preg_quote($k, '~'); }
			$this->patterns[] = '~(?<!\p{L})(' . implode('|', $quoted) . ')(?!\p{L})~u';
		}
	}
}

/**
 * Перевод строки в шаблонах и контроллерах: t('Сохранить').
 * Поддерживает подстановку как sprintf: t('Осталось %d дней', $n).
 */
function t($text) {
	$lang = Lang::instance();
	$out = $lang ? $lang->get($text) : $text;
	if (func_num_args() > 1) {
		$args = func_get_args();
		$args[0] = $out;
		$formatted = @call_user_func_array('sprintf', $args);
		if (is_string($formatted) && $formatted !== '') {
			return $formatted;
		}
	}
	return $out;
}
