<?php namespace ProcessWire;

/**
 * ProcessWire Language Translator 
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 *
 *
 */
class LanguageTranslator extends Wire {

	/**
	 * Language (Page) instance of the current language
	 * 
	 * @var Language
	 *
	 */
	protected $currentLanguage;

	/**
	 * Path where language files are stored
	 *
	 * i.e. language_files path for current $language
	 * 
	 * @var string
 	 *
	 */
	protected $path;

	/**
	 * Root path of installation, same as wire('config')->paths->root
	 * 
	 * @var string
	 *
	 */
	protected $rootPath; 

	/**
	 * Alternate root path for systems where there might be symlinks
	 * 
	 * @var string
	 *
	 */
	protected $rootPath2; 

	/**
	 * Translations for current language, in this format (consistent with the JSON): 
	 *
	 * array(
	 *	'textdomain' => array( 
	 *		'file' => 'filename',
	 * 		'translations' => array(
	 *			'[hash]' => array(
	 *				'text' => string, 	// translated version of the text 
	 *				)
	 *			)
	 * 		)
	 *	); 
	 * 
	 * @var array
	 *
	 */
	protected $textdomains = array();

	/**
	 * Cache of class names and the resulting textdomains
	 * 
	 * @var array
	 *
	 */
	protected $classNamesToTextdomains = array();

	/**
	 * Textdomains of parent classes that can be checked where applicable
	 * 
	 * @var array
	 *
	 */
	protected $parentTextdomains = array(
	// 'className' => array('parent textdomain 1', 'parent textdomain 2', 'etc.') 
	);

	/**
	 * Is current language the default language?
	 * 
	 * @var bool
	 * 
	 */
	protected $isDefaultLanguage = false;

	/**
	 * Construct the translator and set the current language
	 * 
	 * @param Language $currentLanguage
	 *
	 */
	public function __construct(Language $currentLanguage) {
		parent::__construct();
		$currentLanguage->wire($this);
		$this->setCurrentLanguage($currentLanguage);
		$this->rootPath = $this->wire()->config->paths->root; 
		$file = __FILE__; 
		$pos = strpos($file, '/wire/modules/LanguageSupport/'); 
		$this->rootPath2 = $pos ? substr($file, 0, $pos+1) : '';
	}

	/**
	 * Set the current language and reset current stored textdomains
	 * 
	 * @param Language $language
	 * @return $this
	 *
	 */
	public function setCurrentLanguage(Language $language) {

		if($this->currentLanguage && $language->id == $this->currentLanguage->id) return $this; 
		$this->path = $language->filesManager->path(); 

		// we only keep translations for one language in memory at once, 
		// so if the language is changing, we clear out what's already in memory.
		if($this->currentLanguage && $language->id != $this->currentLanguage->id) $this->textdomains = array();
		$this->currentLanguage = $language; 
		$this->isDefaultLanguage = $language->isDefault();

		return $this;
	}

	/**
	 * Return the array template for a textdomain, optionally populating it with data
	 * 
	 * @param string $file
	 * @param string $textdomain
	 * @param array $translations
	 * @return array
	 *
	 */
	protected function textdomainTemplate($file = '', $textdomain = '', array $translations = array()) {
		foreach($translations as $hash => $translation) {
			if(!strlen($translation['text'])) unset($translations[$hash]); 
		}
		if(strpos($file, "\\") !== false && strpos($file, "\\" . basename($file))) {
			// file has MS-DOS style slashes and they are not escapes, convert to unix
			$file = str_replace("\\", '/', $file);
		}
		return array(
			'file' => $file, 
			'textdomain' => $textdomain, 
			'translations' => $translations
		);
	}

	/**
	 * Given an object instance, return the resulting textdomain string
	 *
 	 * This is accomplished with PHP's ReflectionClass to determine the file where the class lives	
	 * and then convert that to a textdomain string. Once determined, we cache it so that we 
	 * don't have to do this again. 
	 * 
	 * @param Wire|object $o
	 * @return string
	 *
	 */
	protected function objectToTextdomain($o) {

		$class = wireClassName($o, false); 

		if(isset($this->classNamesToTextdomains[$class])) {
			$textdomain = $this->classNamesToTextdomains[$class]; 			

		} else {

			$reflection = new \ReflectionClass($o); 	
			$filename = $reflection->getFileName();
		
			if($o instanceof Module) {
				$ds = \DIRECTORY_SEPARATOR; 
				if(strpos($filename, "{$ds}wire{$ds}modules{$ds}") === false) {
					// not a core module
					$config = $this->wire()->config;
					$filename = $this->wire()->files->unixFileName($filename);
					$url = $config->urls($o);
					if($url && strpos($filename, $url) === false && strpos($filename, "/$class/") !== false) {
						// module likely in a symbolic link directory, so determine our own path for textdomain
						// rather than using the one provided by ReflectionClass
						list(, $filename) = explode("/$class/", $filename, 2);
						$filename = $config->paths($class) . $filename;
					}
				}
			}
			
			$textdomain = $this->filenameToTextdomain($filename); 
			$this->classNamesToTextdomains[$class] = $textdomain;
			$parentTextdomains = array();

			// core classes at which translations are no longer applicable
			// $stopClasses = array('Wire', 'WireData', 'WireArray', 'Fieldtype', 'FieldtypeMulti', 'Inputfield', 'Process');
			$stopClasses = array(
				'Wire', 
				'WireData', 
				'WireArray', 
				'Process'
			);
			
			if(__NAMESPACE__) {
				foreach($stopClasses as $class) {
					$stopClass[] = __NAMESPACE__ . "\\$class";
				}
			}

			/** @var \ReflectionClass $parentClass */
			while($parentClass = $reflection->getParentClass()) { 
				if(in_array($parentClass->getShortName(), $stopClasses)) break;
				$parentTextdomains[] = $this->filenameToTextdomain($parentClass->getFileName()); 
				$reflection = $parentClass; 
			}

			$this->parentTextdomains[$textdomain] = $parentTextdomains; 
		}

		return $textdomain;
	}

	/**
	 * Given a filename, convert it to a textdomain string
	 *
	 * @param string $filename
	 * @return string 
	 *
	 */
	public function filenameToTextdomain($filename) {

		if(DIRECTORY_SEPARATOR != '/') $filename = str_replace(DIRECTORY_SEPARATOR, '/', $filename); 

		if(strpos($filename, $this->rootPath) === 0) {
			$filename = str_replace($this->rootPath, '', $filename); 

		} else if($this->rootPath2 && strpos($filename, $this->rootPath2) === 0) {
			// in case /wire/ or /site/ is a symlink
			$filename = str_replace($this->rootPath2, '', $filename); 

		} else { 
			// last resort, may not ever occur, but here anyway
			$pos = strrpos($filename, '/wire/'); 
			if($pos === false) $pos = strrpos($filename, '/site/'); 
			if($pos !== false) $filename = substr($filename, $pos+1);
		}

		// convert FileCompiler paths
		$pos = stripos($filename, '/cache/FileCompiler/');
		if($pos) $filename = substr($filename, $pos+20);

		$textdomain = str_replace(array('/', '\\'), '--', ltrim($filename, '/')); 
		$textdomain = str_replace('.', '-', $textdomain); 

		return strtolower($textdomain);
	}

	/**
	 * Given a textdomain string, convert it to a filename (relative to site root)
	 *
	 * This is determined by loading the textdomain and then grabbing the filename stored in the JSON properties
	 * 
	 * @param string $textdomain
	 * @return string
	 *
	 */
	public function textdomainToFilename($textdomain) {
		if(!isset($this->textdomains[$textdomain])) $this->loadTextdomain($textdomain); 
		return $this->textdomains[$textdomain]['file']; 
	}

	/**
	 * Normalize a string, filename or object to be a textdomain string
	 *
	 * @param string|object $textdomain
	 * @return string
	 * @since 3.0.154 was protected in prior versions
	 *
	 */
	public function textdomainString($textdomain) {

		if(is_string($textdomain) && (strpos($textdomain, DIRECTORY_SEPARATOR) !== false || strpos($textdomain, '/') !== false)) {
			$textdomain = $this->filenameToTextdomain($textdomain); // @werker #424
		} else if(is_object($textdomain)) {
			$textdomain = $this->objectToTextdomain($textdomain);
		} else {
			$textdomain = strtolower($textdomain);
		}

		// just in case there is an extension on it, remove it 
		if(strpos($textdomain, '.')) $textdomain = basename($textdomain, '.json'); 

		return $textdomain;
	}

	/**
	 * Perform a translation in the given $textdomain for $text to the current language
	 *
	 * @param string|object $textdomain Textdomain string, filename, or object. 
	 * @param string $text Text in default language (EN) that needs to be converted to current language. 
	 * @param string $context Optional context label for the text, to differentiate from others that may be the same in English, but not other languages.
 	 * @return string Translation if available, or original EN version if translation not available.
	 *
	 */
	public function getTranslation($textdomain, $text, $context = '') {
		if($this->wire()->hooks->isHooked('LanguageTranslator::getTranslation()')) {
			// if method has hooks, we let them run
			return $this->__call('getTranslation', array($textdomain, $text, $context));
		} else { 
			// if method has no hooks, we avoid any overhead
			return $this->___getTranslation($textdomain, $text, $context);
		}
	}

	/**
	 * Implementation for the getTranslation() function - you should call getTranslation() without underscores instead.
	 *
	 * @param string|object $textdomain Textdomain string, filename, or object. 
	 * @param string $text Text in default language (EN) that needs to be converted to current language. 
	 * @param string $context Optional context label for the text, to differentiate from others that may be the same in English, but not other languages.
 	 * @return string Translation if available, or original EN version if translation not available.
	 *
	 */
	public function ___getTranslation($textdomain, $text, $context = '') {

		// normalize textdomain to be a string, converting from filename or object if necessary
		$textdomain = $this->textdomainString($textdomain); 
		$_text = $text;

		// if the text is already provided in the proper language then no reason to go further
		// if($this->currentLanguage->id == $this->defaultLanguagePageID) return $text; 

		// hash of original text
		$hash = $this->getTextHash($text . $context);

		// translation textdomain hasn't yet been loaded, so load it
		if(!isset($this->textdomains[$textdomain])) $this->loadTextdomain($textdomain); 

		// see if this translation exists
		if(isset($this->textdomains[$textdomain]['translations'][$hash]['text']) 
			&& strlen($this->textdomains[$textdomain]['translations'][$hash]['text'])) { 

			// translation found
			$text = $this->textdomains[$textdomain]['translations'][$hash]['text'];	

		} else if(!empty($this->parentTextdomains[$textdomain])) { 

			// check parent class textdomains
			foreach($this->parentTextdomains[$textdomain] as $td) { 
				if(!isset($this->textdomains[$td])) $this->loadTextdomain($td); 
				if(!empty($this->textdomains[$td]['translations'][$hash]['text'])) { 
					$text = $this->textdomains[$td]['translations'][$hash]['text'];	
					break;
				}
			}
		}
	
		// see if text is available as a common translation
		if($text === $_text && !$this->isDefaultLanguage) {
			$_text = $this->commonTranslation($text);
			if(!empty($_text)) $text = $_text;
		}

		// if text hasn't changed at this point, we'll be returning it in the provided language since we have no translation

		return $text;
	}


	/**
	 * Return ALL translations for the given textdomain
	 * 
	 * @param string $textdomain
	 * @return array
	 *
	 */
	public function getTranslations($textdomain) {

		// normalize to string
		$textdomain = $this->textdomainString($textdomain); 

		// translation textdomain hasn't yet been loaded, so load it
		if(!isset($this->textdomains[$textdomain])) $this->loadTextdomain($textdomain); 

		// return the translations array
		return $this->textdomains[$textdomain]['translations']; 
		
	}

	/**
	 * Set a translation
	 * 
	 * @param string $textdomain
	 * @param string $text
	 * @param string $translation
	 * @param string $context
	 * @return string
	 *
	 */
	public function setTranslation($textdomain, $text, $translation, $context = '') {

		// get the unique hash identifier for the $text
		$hash = $this->getTextHash($text . $context); 

		return $this->setTranslationFromHash($textdomain, $hash, $translation); 
	} 

	/**
	 * Set a translation using an already known hash
	 * 
	 * @param string $textdomain
	 * @param string $hash
	 * @param string $translation
	 * @return string
	 *
	 */
	public function setTranslationFromHash($textdomain, $hash, $translation) {

		// if the textdomain isn't yet setup, then set it up
		if(!isset($this->textdomains[$textdomain]) || !is_array($this->textdomains[$textdomain])) {
			$this->textdomains[$textdomain] = $this->textdomainTemplate();
		}

		// populate the new translation
		if(strlen($translation)) $this->textdomains[$textdomain]['translations'][$hash] = array('text' => $translation); 
			else unset($this->textdomains[$textdomain]['translations'][$hash]); 

		// return the unique hash used to identify the translation
		return $hash; 
	}

	/**
	 * Remove a translation
	 *
	 * @param string $textdomain
	 * @param string $hash May be the translation hash or the translated text. 
 	 * @return $this
	 *
	 */
	public function removeTranslation($textdomain, $hash) {

		if(empty($hash)) return $this; 

		if(isset($this->textdomains[$textdomain]['translations'][$hash])) {
			// remove by $hash
			unset($this->textdomains[$textdomain]['translations'][$hash]); 

		} else {
			// remove by given translation (in $hash)
			$text = $hash; 
			foreach($this->textdomains[$textdomain]['translations'] as $hash => $translation) {
				if($translation['text'] === $text) {
					unset($this->textdomains[$textdomain]['translations'][$hash]); 
					break;
				}
			}
		}

		return $this; 
	}

	/**
	 * Given original $text, issue a unique MD5 key used to reference it
	 * 
	 * @param string $text
	 * @return string
	 *
	 */
	protected function getTextHash($text) {
		if(strpos($text, '\\n') !== false) $text = str_replace('\\n', "\n", $text);
		return md5($text); 
	}

	/**
	 * Get the JSON filename where the current languages class translations are
	 * 
	 * @param string $textdomain
	 * @return string
	 *
	 */
	protected function getTextdomainTranslationFile($textdomain) {
		$textdomain = $this->textdomainString($textdomain); 
		return $this->path . $textdomain . ".json";
	}

	/**
	 * Does a json translation file exist for the given textdomain?
	 * 
	 * @param string $textdomain
	 * @return bool
	 * 
	 */
	public function textdomainFileExists($textdomain) {
		$file = $this->getTextdomainTranslationFile($textdomain);
		return file_exists($file);
	}

	/**
	 * Load translation group $textdomain into the current language translations
	 * 
	 * @param string $textdomain
	 * @return $this
	 *
	 */
	public function loadTextdomain($textdomain) {

		$textdomain = $this->textdomainString($textdomain); 
		if(isset($this->textdomains[$textdomain]) && is_array($this->textdomains[$textdomain])) return $this;
		$file = $this->getTextdomainTranslationFile($textdomain);

		if(is_file($file)) {
			$data = json_decode(file_get_contents($file), true); 
			$this->textdomains[$textdomain] = $this->textdomainTemplate($data['file'], $data['textdomain'], $data['translations']); 

		} else {
			$this->textdomains[$textdomain] = $this->textdomainTemplate('', $textdomain); 
		}

		return $this; 	
	}

	/**
	 * Given a source file to translate, create a new textdomain
	 *
	 * @param string $filename Filename or textdomain that we will be translating, relative to site root.
	 * @param bool $filenameIsTextdomain Specify true if $filename is a textdomain instead.
	 * @param bool $save Whether to save the language
	 * @return string|bool Returns textdomain string if successful, or false if not. 
	 *
	 */
	public function addFileToTranslate($filename, $filenameIsTextdomain = false, $save = true) {

		if($filenameIsTextdomain) {
			$textdomain = $filename;
			$filename = $this->textdomainToFilename($textdomain);
			// $this->message($textdomain . ": " . $filename);
		} else {
			$textdomain = $this->filenameToTextdomain($filename);
		}
		$file = $this->getTextdomainTranslationFile($textdomain); 
		if(empty($file)) {
			$this->error("Unable to get textdomain translation file: $textdomain");
			return false;
		}
		if(is_file($file)) {
			$result = true;
		} else {
			$this->textdomains[$textdomain] = $this->textdomainTemplate(ltrim($filename, '/'), $textdomain);
			$result = file_put_contents($file, $this->encodeJSON($this->textdomains[$textdomain]), LOCK_EX);
			if($result && $this->config->chmodFile) chmod($file, octdec($this->config->chmodFile));
		}

		if($result) {
			$fieldName = 'language_files';
			if(strpos($textdomain, 'wire--') !== 0)	{
				if($this->wire()->fields->get('language_files_site')) {
					$fieldName = 'language_files_site';
				} 
			}
			/** @var Pagefiles $pagefiles */
			$pagefiles = $this->currentLanguage->$fieldName;
			if(!$pagefiles->has(basename($file))) {
				$pagefiles->add($file);
				if($save) $this->currentLanguage->save();
			}
		}

		return $result ? $textdomain : false;
	}

	/**
	 * Save the translation group given by $textdomain to disk in its translation file
	 *
 	 * @param string $textdomain
	 * @return int|bool Number of bytes written or false on failure
	 *
	 */
	public function saveTextdomain($textdomain) {
		if(empty($this->textdomains[$textdomain])) return false;
		$data = $this->textdomains[$textdomain];
		//if(empty($data['file'])) $data['file'] = $this->textdomainToFilename($textdomain);
		$json = $this->encodeJSON($data); 
		$file = $this->getTextdomainTranslationFile($textdomain); 
		$result = file_put_contents($file, $json, LOCK_EX); 
		return $result; 
	}

	/**
	 * Unload the given textdomain string from memory
	 * 
	 * @param string $textdomain
	 *
	 */
	public function unloadTextdomain($textdomain) {
		unset($this->textdomains[$textdomain]); 
	}

	/**
	 * Return the data available for the given $textdomain string
	 * 
	 * @param string $textdomain
	 * @return array
	 *
	 */
	public function getTextdomain($textdomain) { 
		$this->loadTextdomain($textdomain); 
		return isset($this->textdomains[$textdomain]) ? $this->textdomains[$textdomain] : array();
	}

	/**
	 * JSON encode language translation data
	 * 
	 * @param array|string $value
	 * @return string
	 *
	 */
	public function encodeJSON($value) {
		if(defined("JSON_PRETTY_PRINT")) {
			return json_encode($value, JSON_PRETTY_PRINT); 
		} else {
			return json_encode($value); 
		}
	}

	/**
	 * Get a common translation
	 * 
	 * These are commonly used translations that can be used as fallbacks.
	 * 
	 * Returns blank string if given string is not a common phrase. 
	 * Returns given $str if given string is common, but not translated here. 
	 * Returns translated $str if common and translated. 
	 * 
	 * @param string $str
	 * @return string
	 * 
	 */
	public function commonTranslation($str) {
		
		static $level = 0;
		if(strlen($str) >= 15 || $level) return ''; // 15=max length of our common phrases
		$level++;
		$v = '';

		switch(strtolower($str)) {
			case '0-plural': $v = $this->_('0-plural'); break; // Is zero (0) plural or singular? type=radios options=[0-plural:Plural,0-singular:Singular] // i.e. would language use "0 items" (plural) or "0 item" (singular)?
			case 'edit': $v = $this->_('Edit'); break;
			case 'delete': $v = $this->_('Delete'); break;
			case 'save': $v = $this->_('Save'); break;
			case 'save & exit':
			case 'save and exit':
			case 'save + exit': $v = $this->_('Save + Exit'); break;
			case 'cancel': $v = $this->_('Cancel'); break;
			case 'ok': $v = $this->_('Ok'); break;
			case 'new': $v = $this->_('New'); break;
			case 'add': $v = $this->_('Add'); break;
			case 'add new': $v = $this->_('Add New'); break;
			case 'are you sure?': $v = $this->_('Are you sure?'); break;
			case 'confirm': $v = $this->_('Confirm'); break;
			case 'import': $v = $this->_('Import'); break;
			case 'export': $v = $this->_('Export'); break;
			case 'yes': $v = $this->_('Yes'); break;
			case 'no': $v = $this->_('No'); break;
			case 'on': $v = $this->_('On'); break;
			case 'off': $v = $this->_('Off'); break;
			case 'enabled': $v = $this->_('Enabled'); break;
			case 'disabled': $v = $this->_('Disabled'); break;
			case 'example': $v = $this->_('Example'); break;
			case 'please note': $v = $this->_('Please note:'); break;
			case 'note': $v = $this->_('Note'); break;
			case 'notes': $v = $this->_('Notes'); break;
			case 'settings': $v = $this->_('Settings'); break;
			case 'type': $v = $this->_('Type'); break;
			case 'label': $v = $this->_('Label'); break;
			case 'name': $v = $this->_('Name'); break;
			case 'description': $v = $this->_('Description'); break;
			case 'details': $v = $this->_('Details'); break;
			case 'access': $v = $this->_('Access'); break;
			case 'advanced': $v = $this->_('Advanced'); break;
			case 'icon': $v = $this->_('Icon'); break;
			case 'system': $v = $this->_('System'); break;
			case 'modified': $v = $this->_('Modified'); break;
			case 'error': $v = $this->_('Error'); break;
			case ',': $v = $this->_(','); break; // Comma
		}
		
		$level--;
		return $v;
	}

}
