<?php

/**
 * Joomla! Content Management System
 *
 * @copyright  (C) 2007 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\CMS\Language;

use Joomla\CMS\Cache\CacheControllerFactoryInterface;
use Joomla\CMS\Cache\Controller\OutputController;
use Joomla\CMS\Factory;
use Joomla\CMS\Installer\Installer;
use Joomla\CMS\Log\Log;
use Joomla\Database\DatabaseInterface;
use Joomla\Filesystem\Exception\FilesystemException;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
use Joomla\Registry\Registry;
use Joomla\Utilities\ArrayHelper;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Language helper class
 *
 * @since  1.5
 */
class LanguageHelper
{
    /**
     * Builds a list of the system languages which can be used in a select option
     *
     * @param   string   $actualLanguage  Client key for the area
     * @param   string   $basePath        Base path to use
     * @param   boolean  $caching         True if caching is used
     * @param   boolean  $installed       Get only installed languages
     *
     * @return  array  List of system languages
     *
     * @since   1.5
     */
    public static function createLanguageList($actualLanguage, $basePath = JPATH_BASE, $caching = false, $installed = false)
    {
        $list      = [];
        $clientId  = $basePath === JPATH_ADMINISTRATOR ? 1 : 0;
        $languages = $installed ? static::getInstalledLanguages($clientId, true) : self::getKnownLanguages($basePath);

        foreach ($languages as $languageCode => $language) {
            $metadata = $installed ? $language->metadata : $language;

            $list[] = [
                'text'     => $metadata['nativeName'] ?? $metadata['name'],
                'value'    => $languageCode,
                'selected' => $languageCode === $actualLanguage ? 'selected="selected"' : null,
            ];
        }

        return $list;
    }

    /**
     * Builds a list of the system languages which can be used in a select option
     * with both the native name and the english name
     *
     * @param   string   $actualLanguage  Client key for the area
     * @param   string   $basePath        Base path to use
     * @param   boolean  $caching         True if caching is used
     * @param   boolean  $installed       Get only installed languages
     *
     * @return  array  List of system languages
     *
     * @since   5.1.0
     */
    public static function createLanguageListInstall($actualLanguage, $basePath = JPATH_BASE, $caching = false, $installed = false)
    {
        $list      = [];
        $clientId  = $basePath === JPATH_ADMINISTRATOR ? 1 : 0;
        $languages = $installed ? static::getInstalledLanguages($clientId, true) : self::getKnownLanguages($basePath);

        foreach ($languages as $languageCode => $language) {
            $metadata = $installed ? $language->metadata : $language;

            $list[] = [
                'text'     => $metadata['name'] . ' | ' . $metadata['nativeName'] ?? $metadata['name'],
                'value'    => $languageCode,
                'selected' => $languageCode === $actualLanguage ? 'selected="selected"' : null,
            ];
        }

        return $list;
    }

    /**
     * Tries to detect the language.
     *
     * @return  string  locale or null if not found
     *
     * @since   1.5
     */
    public static function detectLanguage()
    {
        if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
            $browserLangs = explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE']);
            $systemLangs  = self::getLanguages();

            foreach ($browserLangs as $browserLang) {
                // Slice out the part before ; on first step, the part before - on second, place into array
                $browserLang         = substr($browserLang, 0, strcspn($browserLang, ';'));
                $primary_browserLang = substr($browserLang, 0, 2);

                // Some browser return only "fr" or "de", so let's try to use it like "fr-fr" or "de-de"
                if (\strlen($browserLang) == 2) {
                    $browserLang = $browserLang . '-' . $browserLang;
                }

                foreach ($systemLangs as $systemLang) {
                    // Take off 3 letters iso code languages as they can't match browsers' languages and default them to en
                    $Jinstall_lang = $systemLang->lang_code;

                    if (\strlen($Jinstall_lang) < 6) {
                        if (strtolower($browserLang) == strtolower(substr($systemLang->lang_code, 0, \strlen($browserLang)))) {
                            return $systemLang->lang_code;
                        }

                        if ($primary_browserLang == substr($systemLang->lang_code, 0, 2)) {
                            $primaryDetectedLang = $systemLang->lang_code;
                        }
                    }
                }

                if (isset($primaryDetectedLang)) {
                    return $primaryDetectedLang;
                }
            }
        }

        return null;
    }

    /**
     * Get available languages
     *
     * @param   string  $key  Array key
     *
     * @return  array  An array of published languages
     *
     * @since   1.6
     */
    public static function getLanguages($key = 'default')
    {
        static $languages = [];

        if (!\count($languages)) {
            // Installation uses available languages
            if (Factory::getApplication()->isClient('installation')) {
                $languages[$key] = [];
                $knownLangs      = self::getKnownLanguages(JPATH_BASE);

                foreach ($knownLangs as $metadata) {
                    // Take off 3 letters iso code languages as they can't match browsers' languages and default them to en
                    $obj               = new \stdClass();
                    $obj->lang_code    = $metadata['tag'];
                    $languages[$key][] = $obj;
                }
            } else {
                /** @var OutputController $cache */
                $cache = Factory::getContainer()->get(CacheControllerFactoryInterface::class)
                    ->createCacheController('output', ['defaultgroup' => 'com_languages']);

                if ($cache->contains('languages')) {
                    $languages = $cache->get('languages');
                } else {
                    $db    = Factory::getDbo();
                    $query = $db->createQuery()
                        ->select('*')
                        ->from($db->quoteName('#__languages'))
                        ->where($db->quoteName('published') . ' = 1')
                        ->order($db->quoteName('ordering') . ' ASC');
                    $db->setQuery($query);

                    $languages['default']   = $db->loadObjectList();
                    $languages['sef']       = [];
                    $languages['lang_code'] = [];

                    if (isset($languages['default'][0])) {
                        foreach ($languages['default'] as $lang) {
                            $languages['sef'][$lang->sef]             = $lang;
                            $languages['lang_code'][$lang->lang_code] = $lang;
                        }
                    }

                    $cache->store($languages, 'languages');
                }
            }
        }

        return $languages[$key];
    }

    /**
     * Get a list of installed languages.
     *
     * @param   integer             $clientId         The client app id.
     * @param   boolean             $processMetaData  Fetch Language metadata.
     * @param   boolean             $processManifest  Fetch Language manifest.
     * @param   string              $pivot            The pivot of the returning array.
     * @param   string              $orderField       Field to order the results.
     * @param   string              $orderDirection   Direction to order the results.
     * @param   ?DatabaseInterface  $db               Database object to use database queries
     *
     * @return  array  Array with the installed languages.
     *
     * @since   3.7.0
     */
    public static function getInstalledLanguages(
        $clientId = null,
        $processMetaData = false,
        $processManifest = false,
        $pivot = 'element',
        $orderField = null,
        $orderDirection = null,
        ?DatabaseInterface $db = null
    ) {
        static $installedLanguages = null;

        if ($installedLanguages === null) {
            /** @var OutputController $cache */
            $cache = Factory::getContainer()->get(CacheControllerFactoryInterface::class)
                ->createCacheController('output', ['defaultgroup' => 'com_languages']);

            if ($cache->contains('installedlanguages')) {
                $installedLanguages = $cache->get('installedlanguages');
            } else {
                $db ??= Factory::getContainer()->get(DatabaseInterface::class);

                $query = $db->createQuery()
                    ->select(
                        [
                            $db->quoteName('element'),
                            $db->quoteName('name'),
                            $db->quoteName('client_id'),
                            $db->quoteName('extension_id'),
                        ]
                    )
                    ->from($db->quoteName('#__extensions'))
                    ->where(
                        [
                            $db->quoteName('type') . ' = ' . $db->quote('language'),
                            $db->quoteName('state') . ' = 0',
                            $db->quoteName('enabled') . ' = 1',
                        ]
                    );

                $installedLanguages = $db->setQuery($query)->loadObjectList();

                $cache->store($installedLanguages, 'installedlanguages');
            }
        }

        $clients   = $clientId === null ? [0, 1] : [(int) $clientId];
        $languages = [
            0 => [],
            1 => [],
        ];

        foreach ($installedLanguages as $language) {
            // If the language client is not needed continue cycle. Drop for performance.
            if (!\in_array((int) $language->client_id, $clients)) {
                continue;
            }

            $lang = $language;

            if ($processMetaData || $processManifest) {
                $clientPath = (int) $language->client_id === 0 ? JPATH_SITE : JPATH_ADMINISTRATOR;
                $metafile   = self::getLanguagePath($clientPath, $language->element) . '/langmetadata.xml';

                if (!is_file($metafile)) {
                    $metafile = self::getLanguagePath($clientPath, $language->element) . '/' . $language->element . '.xml';
                }

                // Process the language metadata.
                if ($processMetaData) {
                    try {
                        $lang->metadata = self::parseXMLLanguageFile($metafile);
                    } catch (\Exception) {
                        // Not able to process xml language file. Fail silently.
                        Log::add(Text::sprintf('JLIB_LANGUAGE_ERROR_CANNOT_LOAD_METAFILE', $language->element, $metafile), Log::WARNING, 'language');

                        continue;
                    }

                    // No metadata found, not a valid language. Fail silently.
                    if (!\is_array($lang->metadata)) {
                        Log::add(Text::sprintf('JLIB_LANGUAGE_ERROR_CANNOT_LOAD_METADATA', $language->element, $metafile), Log::WARNING, 'language');

                        continue;
                    }
                }

                // Process the language manifest.
                if ($processManifest) {
                    try {
                        $lang->manifest = Installer::parseXMLInstallFile($metafile);
                    } catch (\Exception) {
                        // Not able to process xml language file. Fail silently.
                        Log::add(Text::sprintf('JLIB_LANGUAGE_ERROR_CANNOT_LOAD_METAFILE', $language->element, $metafile), Log::WARNING, 'language');

                        continue;
                    }

                    // No metadata found, not a valid language. Fail silently.
                    if (!\is_array($lang->manifest)) {
                        Log::add(Text::sprintf('JLIB_LANGUAGE_ERROR_CANNOT_LOAD_METADATA', $language->element, $metafile), Log::WARNING, 'language');

                        continue;
                    }
                }
            }

            $languages[$language->client_id][] = $lang;
        }

        // Order the list, if needed.
        if ($orderField !== null && $orderDirection !== null) {
            $orderDirection = strtolower($orderDirection) === 'desc' ? -1 : 1;

            foreach ($languages as $cId => $language) {
                // If the language client is not needed continue cycle. Drop for performance.
                if (!\in_array($cId, $clients)) {
                    continue;
                }

                $languages[$cId] = ArrayHelper::sortObjects($language, $orderField, $orderDirection, true, true);
            }
        }

        // Add the pivot, if needed.
        if ($pivot !== null) {
            foreach ($languages as $cId => $language) {
                // If the language client is not needed continue cycle. Drop for performance.
                if (!\in_array($cId, $clients)) {
                    continue;
                }

                $languages[$cId] = ArrayHelper::pivot($language, $pivot);
            }
        }

        return $clientId !== null ? $languages[$clientId] : $languages;
    }

    /**
     * Get a list of content languages.
     *
     * @param   array    $publishedStates  Array with the content language published states. Empty array for all.
     * @param   boolean  $checkInstalled   Check if the content language is installed.
     * @param   string   $pivot            The pivot of the returning array.
     * @param   string   $orderField       Field to order the results.
     * @param   string   $orderDirection   Direction to order the results.
     *
     * @return  array  Array of the content languages.
     *
     * @since   3.7.0
     */
    public static function getContentLanguages(
        $publishedStates = [1],
        $checkInstalled = true,
        $pivot = 'lang_code',
        $orderField = null,
        $orderDirection = null
    ) {
        static $contentLanguages = null;

        if ($contentLanguages === null) {
            /** @var OutputController $cache */
            $cache = Factory::getContainer()->get(CacheControllerFactoryInterface::class)
                ->createCacheController('output', ['defaultgroup' => 'com_languages']);

            if ($cache->contains('contentlanguages')) {
                $contentLanguages = $cache->get('contentlanguages');
            } else {
                $db = Factory::getDbo();

                $query = $db->createQuery()
                    ->select('*')
                    ->from($db->quoteName('#__languages'));

                $contentLanguages = $db->setQuery($query)->loadObjectList();

                $cache->store($contentLanguages, 'contentlanguages');
            }
        }

        $languages = $contentLanguages;

        // B/C layer. Before 3.8.3.
        if ($publishedStates === true) {
            $publishedStates = [1];
        } elseif ($publishedStates === false) {
            $publishedStates = [];
        }

        // Check the language published state, if needed.
        if (\count($publishedStates) > 0) {
            foreach ($languages as $key => $language) {
                if (!\in_array((int) $language->published, $publishedStates, true)) {
                    unset($languages[$key]);
                }
            }
        }

        // Check if the language is installed, if needed.
        if ($checkInstalled) {
            $languages = array_values(array_intersect_key(ArrayHelper::pivot($languages, 'lang_code'), static::getInstalledLanguages(0)));
        }

        // Order the list, if needed.
        if ($orderField !== null && $orderDirection !== null) {
            $languages = ArrayHelper::sortObjects($languages, $orderField, strtolower($orderDirection) === 'desc' ? -1 : 1, true, true);
        }

        // Add the pivot, if needed.
        if ($pivot !== null) {
            $languages = ArrayHelper::pivot($languages, $pivot);
        }

        return $languages;
    }

    /**
     * Parse strings from a language file.
     *
     * @param   string   $fileName  The language ini file path.
     * @param   boolean  $debug     If set to true debug language ini file.
     *
     * @return  array  The strings parsed.
     *
     * @since   3.9.0
     * @throws  \RuntimeException On debug
     */
    public static function parseIniFile($fileName, $debug = false)
    {
        // Check if file exists.
        if (!is_file($fileName)) {
            return [];
        }

        $cacheFile = JPATH_CACHE . '/language/' . ltrim(str_replace([JPATH_ROOT, '/'], ['', '-'], $fileName), '-') . '.' . filemtime($fileName) . '.php';

        if (is_file($cacheFile)) {
            $result = include $cacheFile;

            if (\is_array($result)) {
                return $result;
            }

            // When $result is not an array, the cache file is corrupted, we will delete it and have it regenerated
            try {
                File::delete($cacheFile);
            } catch (FilesystemException $e) {
                // We ignore the error, as the file is for caching only.
            }
        }

        // This was required for https://github.com/joomla/joomla-cms/issues/17198 but not sure what server setup
        // issue it is solving
        $disabledFunctions      = explode(',', \ini_get('disable_functions'));
        $isParseIniFileDisabled = \in_array('parse_ini_file', array_map('trim', $disabledFunctions));

        // Capture hidden PHP errors from the parsing.
        set_error_handler(static function ($errno, $err) {
            throw new \Exception($err);
        }, \E_WARNING);

        try {
            if (!\function_exists('parse_ini_file') || $isParseIniFileDisabled) {
                $contents = file_get_contents($fileName);
                $strings  = parse_ini_string($contents, false, INI_SCANNER_RAW);
            } else {
                $strings = parse_ini_file($fileName, false, INI_SCANNER_RAW);
            }
        } catch (\Exception $e) {
            if ($debug) {
                throw new \RuntimeException($e->getMessage());
            }

            return [];
        } finally {
            restore_error_handler();
        }

        if (!\is_array($strings)) {
            $strings = [];
        }

        // Ini files are processed in the "RAW" mode of parse_ini_string, leaving escaped quotes untouched - lets postprocess them
        $strings = str_replace('\"', '"', $strings);

        // Write cache
        try {
            Folder::create(\dirname($cacheFile));

            $data         = "<?php\ndefined('_JEXEC') or die;\nreturn " . var_export($strings, true) . ";\n";
            $bytesWritten = file_put_contents($cacheFile, $data);

            if ($bytesWritten === false || $bytesWritten < \strlen($data)) {
                if (is_file($cacheFile)) {
                    File::delete($cacheFile);
                }

                throw new FilesystemException('Unable to write cache file');
            }
        } catch (FilesystemException) {
            try {
                Log::add(
                    Text::sprintf('JLIB_LANGUAGE_ERROR_CANNOT_WRITE_CACHE', str_replace(JPATH_ROOT, '', $cacheFile)),
                    Log::WARNING,
                    'language'
                );
            } catch (\RuntimeException) {
                // Ignore logging errors
            }
        }

        return $strings;
    }

    /**
     * Save strings to a language file.
     *
     * @param   string  $fileName  The language ini file path.
     * @param   array   $strings   The array of strings.
     *
     * @return  boolean  True if saved, false otherwise.
     *
     * @since   3.7.0
     */
    public static function saveToIniFile($fileName, array $strings)
    {
        // Escape double quotes.
        foreach ($strings as $key => $string) {
            $strings[$key] = addcslashes($string, '"');
        }

        // Write override.ini file with the strings.
        $registry = new Registry($strings);

        return File::write($fileName, $registry->toString('INI'));
    }

    /**
     * Checks if a language exists.
     *
     * This is a simple, quick check for the directory that should contain language files for the given user.
     *
     * @param   string  $lang      Language to check.
     * @param   string  $basePath  Optional path to check.
     *
     * @return  boolean  True if the language exists.
     *
     * @since   3.7.0
     */
    public static function exists($lang, $basePath = JPATH_BASE)
    {
        static $paths = [];

        // Return false if no language was specified
        if (!$lang) {
            return false;
        }

        $path = $basePath . '/language/' . $lang;

        // Return previous check results if it exists
        if (isset($paths[$path])) {
            return $paths[$path];
        }

        // Check if the language exists
        $paths[$path] = is_dir($path);

        return $paths[$path];
    }

    /**
     * Returns an associative array holding the metadata.
     *
     * @param   string  $lang  The name of the language.
     *
     * @return  mixed  If $lang exists return key/value pair with the language metadata, otherwise return NULL.
     *
     * @since   3.7.0
     */
    public static function getMetadata($lang)
    {
        $file = self::getLanguagePath(JPATH_BASE, $lang) . '/langmetadata.xml';

        if (!is_file($file)) {
            $file = self::getLanguagePath(JPATH_BASE, $lang) . '/' . $lang . '.xml';
        }

        $result = null;

        if (is_file($file)) {
            $result = self::parseXMLLanguageFile($file);
        }

        if (empty($result)) {
            return;
        }

        return $result;
    }

    /**
     * Returns a list of known languages for an area
     *
     * @param   string  $basePath  The basepath to use
     *
     * @return  array  key/value pair with the language file and real name.
     *
     * @since   3.7.0
     */
    public static function getKnownLanguages($basePath = JPATH_BASE)
    {
        return self::parseLanguageFiles(self::getLanguagePath($basePath));
    }

    /**
     * Get the path to a language
     *
     * @param   string  $basePath  The basepath to use.
     * @param   string  $language  The language tag.
     *
     * @return  string  language related path or null.
     *
     * @since   3.7.0
     */
    public static function getLanguagePath($basePath = JPATH_BASE, $language = null)
    {
        return $basePath . '/language' . (!empty($language) ? '/' . $language : '');
    }

    /**
     * Searches for language directories within a certain base dir.
     *
     * @param   string  $dir  directory of files.
     *
     * @return  array  Array holding the found languages as filename => real name pairs.
     *
     * @since   3.7.0
     */
    public static function parseLanguageFiles($dir = null)
    {
        $languages = [];

        // Search main language directory for subdirectories
        foreach (glob($dir . '/*', GLOB_NOSORT | GLOB_ONLYDIR) as $directory) {
            // But only directories with lang code format
            if (preg_match('#/[a-z]{2,3}-[A-Z]{2}$#', $directory)) {
                $dirPathParts = pathinfo($directory);
                $file         = $directory . '/langmetadata.xml';

                if (!is_file($file)) {
                    $file = $directory . '/' . $dirPathParts['filename'] . '.xml';
                }

                if (!is_file($file)) {
                    continue;
                }

                try {
                    // Get installed language metadata from xml file and merge it with lang array
                    if ($metadata = self::parseXMLLanguageFile($file)) {
                        $languages = array_replace($languages, [$dirPathParts['filename'] => $metadata]);
                    }
                } catch (\RuntimeException) {
                    // Ignore it
                }
            }
        }

        return $languages;
    }

    /**
     * Parse XML file for language information.
     *
     * @param   string  $path  Path to the XML files.
     *
     * @return  array  Array holding the found metadata as a key => value pair.
     *
     * @since   3.7.0
     * @throws  \RuntimeException
     */
    public static function parseXMLLanguageFile($path)
    {
        if (!is_readable($path)) {
            throw new \RuntimeException('File not found or not readable');
        }

        // Try to load the file
        $xml = simplexml_load_file($path);

        if (!$xml) {
            return;
        }

        // Check that it's a metadata file
        if ((string) $xml->getName() !== 'metafile') {
            return;
        }

        $metadata = [];

        foreach ($xml->metadata->children() as $child) {
            $metadata[$child->getName()] = (string) $child;
        }

        return $metadata;
    }
}
