<?php
/*
 * This file is part of MODX Revolution.
 *
 * Copyright (c) MODX, LLC. All Rights Reserved.
 *
 * For complete copyright and license information, see the COPYRIGHT and LICENSE
 * files found in the top-level directory of this distribution.
 */

/**
 * The lexicon handling class. Handles all lexicon topics by loading and storing their entries into a cached array.
 * Also considers database-based overrides for specific lexicon entries that preserve the originals and allow reversion.
 *
 * @package modx
 */
class modLexicon {
    /**
     * Reference to the MODX instance.
     *
     * @var modX $modx
     * @access protected
     */
    public $modx = null;
    /**
     * The actual language array.
     *
     * @todo Separate into separate arrays for each namespace (and maybe topic)
     * so that no namespacing in lexicon entries is needed. Maybe keep a master
     * array of entries, but then have subarrays for topic-specific referencing.
     *
     * @var array $_lexicon
     * @access protected
     */
    protected $_lexicon = array();
    /**
     * Directories to search for language strings in.
     *
     * @deprecated
     * @var array $_paths
     * @access protected
     */
    protected $_paths = array();
    /**
     * An array of loaded topic strings
     *
     * @var array $_loadedTopics
     */
    protected $_loadedTopics = array();
    /**
     * An array of configuration properties
     *
     * @var array $config
     */
    protected $config = array();
    
    /**
     * Creates the modLexicon instance.
     *
     * @constructor
     * @param xPDO $modx A reference to the modX instance.
     * @param array $config An array of configuration properties
     */
    function __construct(xPDO &$modx,array $config = array()) {
        $this->modx =& $modx;
        $this->_paths = array(
             'core' => $this->modx->getOption('core_path') . 'cache/lexicon/',
        );
        $this->_lexicon = array($this->modx->getOption('cultureKey',null,'en') => array());
        $this->config = array_merge($config,array());
    }

    /**
     * Clears the lexicon cache for the specified path.
     *
     * @access public
     * @param string $path The path to clear.
     * @return string The results of the cache clearing.
     */
    public function clearCache($path = '') {
        $path = 'lexicon/'.$path;
        return $this->modx->cacheManager->refresh(array(
            'lexicon_topics' => array($path),
        ));
    }

    /**
     * Returns if the key exists in the lexicon.
     *
     * @access public
     * @param string $index
     * @return boolean True if exists.
     */
    public function exists($index,$language = '') {
        $language = !empty($language) ? $language : $this->modx->getOption('cultureKey',null,'en');
        return (is_string($index) && isset($this->_lexicon[$language][$index]));
    }

    /**
     * Accessor method for the lexicon array.
     *
     * @access public
     * @param string $prefix If set, will only return the lexicon entries with this prefix.
     * @param boolean $removePrefix If true, will strip the prefix from the returned indexes
     * @param string $language
     * @return array The internal lexicon.
     */
    public function fetch($prefix = '',$removePrefix = false,$language = '') {
        $language = !empty($language) ? $language : $this->modx->getOption('cultureKey',null,'en');
        if (!empty($prefix)) {
            $lex = array();
            $lang = $this->_lexicon[$language];
            if (is_array($lang)) {
                foreach ($lang as $k => $v) {
                    if (strpos($k,$prefix) !== false) {
                        $key = $removePrefix ? str_replace($prefix,'',$k) : $k;
                        $lex[$key] = $v;
                    }
                }
            }
            return $lex;
        }
        return $this->_lexicon[$language];
    }

    /**
     * Return the cache key representing the specified lexicon topic.
     *
     * @access public
     * @param string $namespace The namespace for the topic
     * @param string $topic The topic to grab
     * @param string $language The language for the topic
     * @return string The cache key for the specified topic
     */
    public function getCacheKey($namespace = 'core',$topic = 'default',$language = '') {
        if (empty($namespace)) $namespace = 'core';
        if (empty($topic)) $topic = 'default';
        if (empty($language)) $language = $this->modx->getOption('cultureKey',null,'en');
        return 'lexicon/'.$language.'/'.$namespace.'/'.$topic;
    }

    /**
     * Loads a variable number of topic areas. They must reside as topicname.
     * inc.php files in their proper culture directory. Can load an infinite
     * number of topic areas via a dynamic number of arguments.
     *
     * They are loaded by language:namespace:topic, namespace:topic, or just
     * topic. Examples: $modx->lexicon->load('en:core:snippet'); $modx->lexicon-
     * >load ('demo:test'); $modx->lexicon->load('chunk');
     *
     * @access public
     */
    public function load() {
        $topics = func_get_args(); /* allow for dynamic number of lexicons to load */

        if ($this->modx->context && $this->modx->context->get('key') == 'mgr') {
            $defaultLanguage = $this->modx->getOption('manager_language',null,$this->modx->getOption('cultureKey',null,'en'));
        } else {
            $defaultLanguage = $this->modx->getOption('cultureKey',null,'en');
        }

        foreach ($topics as $topicStr) {
            if (!is_string($topicStr) || $topicStr == '') continue;
            if (in_array($topicStr,$this->_loadedTopics)) continue;
            $nspos = strpos($topicStr,':');
            $topic = str_replace('.','/',$topicStr); /** @deprecated 2.0.0 Allow for lexicon subdirs */

            /* if no namespace, search all lexicons */
            if ($nspos === false) {
                foreach ($this->_paths as $namespace => $path) {
                    $entries = $this->loadCache($namespace,$topic);
                    if (is_array($entries)) {
                        if (!array_key_exists($defaultLanguage,$this->_lexicon)) $this->_lexicon[$defaultLanguage] = array();
                        $this->_lexicon[$defaultLanguage] = is_array($this->_lexicon[$defaultLanguage]) ? array_merge($this->_lexicon[$defaultLanguage],$entries) : $entries;
                    }
                }
            } else { /* if namespace, search specified lexicon */
                $params = explode(':',$topic);
                if (count($params) <= 2) {
                    $language = $defaultLanguage;
                    $namespace = $params[0];
                    $topic_parsed = $params[1];
                } else {
                    $language = $params[0];
                    $namespace = $params[1];
                    $topic_parsed = $params[2];
                }

                $englishEntries = $language != 'en' ? $this->loadCache($namespace,$topic_parsed,'en') : false;
                $entries = $this->loadCache($namespace,$topic_parsed,$language);
                if (!is_array($entries)) {
                    if (is_string($entries) && !empty($entries)) $entries = $this->modx->fromJSON($entries);
                    if (empty($entries)) $entries = array();
                }
                if (is_array($englishEntries) && !empty($englishEntries)) {
                    $entries = array_merge($englishEntries,$entries);
                }
                if (is_array($entries)) {
                    $this->_loadedTopics[] = $topicStr;
                    if (!array_key_exists($language,$this->_lexicon)) $this->_lexicon[$language] = array();
                    $this->_lexicon[$language] = is_array($this->_lexicon[$language]) ? array_merge($this->_lexicon[$language], $entries) : $entries;
                }
            }
        }
    }

    /**
     * Loads a lexicon topic from the cache. If not found, tries to generate a
     * cache file from the database.
     *
     * @access public
     * @param string $namespace The namespace to load from. Defaults to 'core'.
     * @param string $topic The topic to load. Defaults to 'default'.
     * @param string $language The language to load. Defaults to 'en'.
     * @return array The loaded lexicon array.
     */
    public function loadCache($namespace = 'core', $topic = 'default', $language = '') {
        if (empty($language)) $language = $this->modx->getOption('cultureKey',null,'en');
        $key = $this->getCacheKey($namespace, $topic, $language);
        $enableCache = ($namespace != 'core' && !$this->modx->getOption('cache_noncore_lexicon_topics',null,true)) ? false : true;

        if (!$this->modx->cacheManager) {
            $this->modx->getCacheManager();
        }
        $cached = $this->modx->cacheManager->get($key, array(
            xPDO::OPT_CACHE_KEY => $this->modx->getOption('cache_lexicon_topics_key', null, 'lexicon_topics'),
            xPDO::OPT_CACHE_HANDLER => $this->modx->getOption('cache_lexicon_topics_handler', null, $this->modx->getOption(xPDO::OPT_CACHE_HANDLER)),
            xPDO::OPT_CACHE_FORMAT => (integer) $this->modx->getOption('cache_lexicon_topics_format', null, $this->modx->getOption(xPDO::OPT_CACHE_FORMAT, null, xPDOCacheManager::CACHE_PHP)),
        ));
        if (!$enableCache || $cached == null) {
            $results= false;

            /* load file-based lexicon */
            $results = $this->getFileTopic($language,$namespace,$topic);
            if ($results === false) { /* default back to en */
                $results = $this->getFileTopic('en',$namespace,$topic);
                if ($results === false) {
                    $results = array();
                }
            }

            /* get DB overrides */
            $c= $this->modx->newQuery('modLexiconEntry');
            $c->innerJoin('modNamespace','Namespace');
            $c->where(array(
                'modLexiconEntry.topic' => $topic,
                'modLexiconEntry.language' => $language,
                'Namespace.name' => $namespace,
            ));
            $c->sortby($this->modx->getSelectColumns('modLexiconEntry','modLexiconEntry','',array('name')),'ASC');
            $entries= $this->modx->getCollection('modLexiconEntry',$c);
            if (!empty($entries)) {
                /** @var modLexiconEntry $entry */
                foreach ($entries as $entry) {
                    $results[$entry->get('name')]= $entry->get('value');
                }
            }
            if ($enableCache) {
                $cached = $this->modx->cacheManager->generateLexiconTopic($key,$results);
            } else {
                $cached = $results;
            }
        }
        if (empty($cached)) {
            $this->modx->log(xPDO::LOG_LEVEL_DEBUG, "An error occurred while trying to cache {$key} (lexicon/language/namespace/topic)");
        }
        return $cached;
    }

    /**
     * Get entries from file-based lexicon topic
     *
     * @param string $language The language to filter by.
     * @param string $namespace The namespace to filter by.
     * @param string $topic The topic to filter by.
     * @return array An array of lexicon entries in key - value pairs for the specified filter.
     */
    public function getFileTopic($language = 'en',$namespace = 'core',$topic = 'default') {
        $corePath = $this->getNamespacePath($namespace);
        $corePath = str_replace(array(
            '{base_path}',
            '{core_path}',
            '{assets_path}',
        ),array(
            $this->modx->getOption('base_path'),
            $this->modx->getOption('core_path'),
            $this->modx->getOption('assets_path'),
        ),$corePath);
        $topicPath = str_replace('//','/',$corePath.'/lexicon/'.$language.'/'.$topic.'.inc.php');
        $results = array();
        $_lang = array();
        if (file_exists($topicPath)) {
            include $topicPath;
            $results = $_lang;
        } else {
            return false;
        }
        return $results;
    }

    /**
     * Get the path of the specified Namespace
     *
     * @param string $namespace The key of the Namespace
     * @return string The path for the Namespace
     */
    public function getNamespacePath($namespace = 'core') {
        $corePath = $this->modx->getOption('core_path',null,MODX_CORE_PATH);
        if ($namespace != 'core') {
            /** @var modNamespace $namespaceObj */
            $namespaceObj = $this->modx->getObject('modNamespace',$namespace);
            if ($namespaceObj) {
                $corePath = $namespaceObj->getCorePath();
            }
        }
        return $corePath;
    }

    /**
     * Get a list of available Topics when given a Language and Namespace.
     *
     * @param string $language The language to filter by.
     * @param string $namespace The language to filter by.
     * @return array An array of Topic names.
     */
    public function getTopicList($language = 'en',$namespace = 'core') {
        $corePath = $this->getNamespacePath($namespace);
        $lexPath = str_replace('//','/',$corePath.'/lexicon/'.$language.'/');

        $topics = array();
        if (!is_dir($lexPath)) return $topics;
        /** @var DirectoryIterator $topic */
        foreach (new DirectoryIterator($lexPath) as $topic) {
            if (in_array($topic,array('.','..','.svn','.git','_notes'))) continue;
            if (!$topic->isReadable()) continue;

            if ($topic->isFile()) {
                $fileName = $topic->getFilename();
                if (strpos($fileName,'.inc.php')) {
                    $topics[] = str_replace('.inc.php','',$fileName);
                }
            }
        }

        $c = $this->modx->newQuery('modLexiconEntry');
        $c->where(array(
            'namespace' => $namespace,
            'topic:NOT IN' => $topics,
        ));
        $c->select(array('topic'));
        $c->query['distinct'] = 'DISTINCT';
        if ($c->prepare() && $c->stmt->execute()) {
            $entries = $c->stmt->fetchAll(\PDO::FETCH_ASSOC);
            if (is_array($entries) and count($entries) > 0) {
                foreach ($entries as $v) {
                    $topics[] = $v['topic'];
                }
            }
        }

        sort($topics);
        return $topics;
    }

    /**
     * Get a list of available languages for a Namespace.
     *
     * @param string $namespace The Namespace to filter by.
     * @return array An array of available languages
     */
    public function getLanguageList($namespace = 'core') {
        $corePath = $this->getNamespacePath($namespace);
        $lexPath = str_replace('//','/',$corePath.'/lexicon/');
        if (!is_dir($lexPath)) {
            return array();
        }
        $languages = array();
        /** @var DirectoryIterator $language */
        foreach (new DirectoryIterator($lexPath) as $language) {
            if (in_array($language,array('.','..','.svn','.git','_notes','country'))) continue;
            if (!$language->isReadable()) continue;

            if ($language->isDir()) {
                $languages[] = $language->getFilename();
            }
        }

        $c = $this->modx->newQuery('modLexiconEntry');
        $c->where(array(
            'namespace' => $namespace,
            'language:NOT IN' => $languages,
        ));
        $c->select(array('language'));
        $c->query['distinct'] = 'DISTINCT';
        if ($c->prepare() && $c->stmt->execute()) {
            $entries = $c->stmt->fetchAll(\PDO::FETCH_ASSOC);
            if (is_array($entries) and count($entries) > 0) {
                foreach ($entries as $v) {
                    $languages[] = $v['language'];
                }
            }
        }

        sort($languages);
        return $languages;
    }

    /**
     * Get a lexicon string by its index.
     *
     * @access public
     * @param string $key The key of the lexicon string.
     * @param array $params An assocative array of placeholder
     * keys and values to parse
     * @param string $language
     * @return string The text of the lexicon key, blank if not found.
     */
    public function process($key,array $params = array(),$language = '') {
        $language = !empty($language) ? $language : $this->modx->getOption('cultureKey',null,'en');
        /* make sure key exists */
        if (!is_string($key) || !isset($this->_lexicon[$language][$key])) {
            $this->modx->log(xPDO::LOG_LEVEL_DEBUG,'Language string not found: "'.$key.'"');
            return $key;
        }
        /* if params are passed, allow for parsing of [[+key]] values to strings */
        return empty($params)
            ? $this->_lexicon[$language][$key]
            : $this->_parse($this->_lexicon[$language][$key],$params);
    }

    /**
     * Sets a lexicon key to a value. Not recommended, since doesn't query the
     * database.
     *
     * @access public
     * @param string|array $keys Either an array of array pairs of key/values or
     * a key string.
     * @param string $text The text to set, if the first parameter is a string.
     * @param string $language The language to set the key in. Defaults to current.
     */
    public function set($keys, $text = '', $language = '') {
        $language = !empty($language) ? $language : $this->modx->getOption('cultureKey',null,$language);
        if (is_array($keys)) {
            foreach ($keys as $key => $str) {
                if ($key == '') continue;
                $this->_lexicon[$language][$key] = $str;
            }
        } else if (is_string($keys) && $keys != '') {
            $this->_lexicon[$language][$keys] = $text;
        }
    }

    /**
     * Parses a lexicon string, replacing placeholders with
     * specified strings.
     *
     * @access private
     * @param string $str The string to parse
     * @param array $params An associative array of keys to replace
     * @return string The processed string
     */
    private function _parse($str,$params) {
        if (!$str) return '';
        if (empty($params)) return $str;

        $params = $this->_flatten($params);
        foreach ($params as $k => $v) {
            $str = str_replace('[[+'.$k.']]',$v,$str);
        }
        return $str;
    }

    /**
     * Flattens an array by dot notation
     *
     * @access private
     * @param array $array The array to flatten
     * @return array The processed array
     */
    private function _flatten($array) {
        $result = array();
        if (is_array($array)) {
            $iterator = new RecursiveIteratorIterator(new RecursiveArrayIterator($array));
            foreach ($iterator as $value) {
                $keys = array();
                foreach (range(0, $iterator->getDepth()) as $depth) {
                    $keys[] = $iterator->getSubIterator($depth)->key();
                }
                $result[ join('.', $keys) ] = $value;
            }
        }
        return $result;
    }

    /**
     * Returns the total # of entries in the active lexicon
     * @param string $language
     * @return int
     */
    public function total($language = '') {
        $language = !empty($language) ? $language : $this->modx->getOption('cultureKey',null,'en');
        return count($this->_lexicon[$language]);
    }

    /**
     * Completely clears the lexicon
     * @param string $language
     * @return void
     */
    public function clear($language = '') {
        if (!empty($language)) {
            $this->_lexicon[$language] = array();
        } else {
            $this->_lexicon = array($this->modx->getOption('cultureKey',null,'en') => array());
        }
    }
}
