<?php

/**
 * Part of the Joomla Framework Language Package
 *
 * @copyright  Copyright (C) 2005 - 2020 Open Source Matters, Inc. All rights reserved.
 * @license    GNU General Public License version 2 or later; see LICENSE
 */

namespace Joomla\Language;

/**
 * Languages/translation handler class
 *
 * @since  1.0
 */
class Language
{
    /**
     * Debug language, If true, highlights if string isn't found.
     *
     * @var    boolean
     * @since  1.0
     */
    protected $debug = false;

    /**
     * The default language, used when a language file in the requested language does not exist.
     *
     * @var    string
     * @since  1.0
     */
    protected $default = 'en-GB';

    /**
     * An array of orphaned text.
     *
     * @var    array
     * @since  1.0
     */
    protected $orphans = [];

    /**
     * Array holding the language metadata.
     *
     * @var    array
     * @since  1.0
     */
    protected $metadata;

    /**
     * Array holding the language locale or null.
     *
     * @var    ?array
     * @since  1.0
     */
    protected $locale;

    /**
     * The language to load.
     *
     * @var    string
     * @since  1.0
     */
    protected $lang;

    /**
     * A nested array of language files that have been loaded
     *
     * @var    array
     * @since  1.0
     */
    protected $paths = [];

    /**
     * List of language files that are in error state
     *
     * @var    array
     * @since  1.0
     */
    protected $errorfiles = [];

    /**
     * An array of used text, used during debugging.
     *
     * @var    array
     * @since  1.0
     */
    protected $used = [];

    /**
     * Counter for number of loads.
     *
     * @var    integer
     * @since  1.0
     */
    protected $counter = 0;

    /**
     * An array used to store overrides.
     *
     * @var    array
     * @since  1.0
     */
    protected $override = [];

    /**
     * The localisation object.
     *
     * @var    LocaliseInterface
     * @since  2.0
     */
    protected $localise;

    /**
     * LanguageHelper object
     *
     * @var    LanguageHelper
     * @since  2.0
     */
    protected $helper;

    /**
     * The base path to the language folder
     *
     * @var    string
     * @since  2.0
     */
    protected $basePath;

    /**
     * MessageCatalogue object
     *
     * @var    MessageCatalogue
     * @since  2.0
     */
    protected $catalogue;

    /**
     * Language parser registry
     *
     * @var    ParserRegistry
     * @since  2.0
     */
    protected $parserRegistry;

    /**
     * Constructor activating the default information of the language.
     *
     * @param   ParserRegistry  $parserRegistry  A registry containing the supported file parsers
     * @param   string          $path            The base path to the language folder
     * @param   string          $lang            The language
     * @param   boolean         $debug           Indicates if language debugging is enabled
     *
     * @since   1.0
     */
    public function __construct(ParserRegistry $parserRegistry, string $path, string $lang = '', bool $debug = false)
    {
        if (empty($path)) {
            throw new \InvalidArgumentException(
                'The $path variable cannot be empty when creating a new Language object'
            );
        }

        $this->basePath = $path;
        $this->helper   = new LanguageHelper();

        $this->lang = $lang ?: $this->default;

        $this->metadata = $this->helper->getMetadata($this->lang, $this->basePath);
        $this->setDebug($debug);

        $this->parserRegistry = $parserRegistry;

        $basePath = $this->helper->getLanguagePath($this->basePath);

        $filename = $basePath . "/overrides/$lang.override.ini";

        if (file_exists($filename) && $contents = $this->parse($filename)) {
            if (\is_array($contents)) {
                // Sort the underlying heap by key values to optimize merging
                ksort($contents, SORT_STRING);
                $this->override = $contents;
            }

            unset($contents);
        }

        // Grab a localisation file
        $this->localise = (new LanguageFactory())->getLocalise($lang, $path);

        $this->catalogue = new MessageCatalogue($this->lang);

        $this->load();
    }

    /**
     * Translate function, mimics the php gettext (alias _) function.
     *
     * The function checks if $jsSafe is true, then if $interpretBackslashes is true.
     *
     * @param   string   $string                The string to translate
     * @param   boolean  $jsSafe                Make the result JavaScript safe
     * @param   boolean  $interpretBackSlashes  Interpret \t and \n
     *
     * @return  string  The translation of the string
     *
     * @see     Language::translate()
     * @since   1.0
     * @deprecated  3.0  Use translate instead
     */
    public function _($string, $jsSafe = false, $interpretBackSlashes = true)
    {
        trigger_deprecation(
            'joomla/language',
            '2.0.0',
            '%s() is deprecated and will be removed in 3.0, use %s::translate() instead.',
            __METHOD__,
            self::class
        );

        return $this->translate((string) $string, (bool) $jsSafe, (bool) $interpretBackSlashes);
    }

    /**
     * Translate function, mimics the php gettext (alias _) function.
     *
     * The function checks if $jsSafe is true, then if $interpretBackslashes is true.
     *
     * @param   string   $string                The string to translate
     * @param   boolean  $jsSafe                Make the result JavaScript safe
     * @param   boolean  $interpretBackSlashes  Interpret \t and \n
     *
     * @return  string  The translation of the string
     *
     * @since   2.0
     */
    public function translate(string $string, bool $jsSafe = false, bool $interpretBackSlashes = true): string
    {
        // Detect empty string
        if ($string == '') {
            return '';
        }

        $key = strtoupper($string);

        if ($this->catalogue->hasMessage($key)) {
            $string = $this->debug ? '**' . $this->catalogue->getMessage($key) . '**' : $this->catalogue->getMessage($key);

            // Store debug information
            if ($this->debug) {
                $caller = $this->getCallerInfo();

                if (!array_key_exists($key, $this->used)) {
                    $this->used[$key] = [];
                }

                $this->used[$key][] = $caller;
            }
        } else {
            if ($this->debug) {
                $caller           = $this->getCallerInfo();
                $caller['string'] = $string;

                if (!array_key_exists($key, $this->orphans)) {
                    $this->orphans[$key] = [];
                }

                $this->orphans[$key][] = $caller;

                $string = '??' . $string . '??';
            }
        }

        if ($jsSafe) {
            // JavaScript filter
            $string = addslashes($string);
        } elseif ($interpretBackSlashes) {
            if (strpos($string, '\\') !== false) {
                // Interpret \n and \t characters
                $string = str_replace(['\\\\', '\t', '\n'], ['\\', "\t", "\n"], $string);
            }
        }

        return $string;
    }

    /**
     * Transliterate function
     *
     * This method processes a string and replaces all accented UTF-8 characters by unaccented ASCII-7 "equivalents".
     *
     * @param   string  $string  The string to transliterate.
     *
     * @return  string  The transliteration of the string.
     *
     * @since   1.0
     * @throws  \RuntimeException
     */
    public function transliterate($string)
    {
        $transliterated_string = $this->localise->transliterate($string);

        // The transliterate method can return false if there isn't a fully valid UTF-8 string entered
        if ($transliterated_string === false) {
            throw new \RuntimeException(sprintf('Invalid UTF-8 was detected in the string "%s"', $string));
        }

        return $transliterated_string;
    }

    /**
     * Returns an array of suffixes for plural rules.
     *
     * @param   integer  $count  The count number the rule is for.
     *
     * @return  string[]  The array of suffixes.
     *
     * @since   1.0
     */
    public function getPluralSuffixes($count)
    {
        return $this->localise->getPluralSuffixes($count);
    }

    /**
     * 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.
     *
     * @see     LanguageHelper::exists()
     * @since   1.0
     * @deprecated  3.0  Use LanguageHelper::exists() instead
     */
    public static function exists($lang, $basePath = '')
    {
        trigger_deprecation(
            'joomla/language',
            '2.0.0',
            '%s() is deprecated and will be removed in 3.0, use %s::exists() instead.',
            __METHOD__,
            LanguageHelper::class
        );

        return (new LanguageHelper())->exists($lang, $basePath);
    }

    /**
     * Loads a single language file and appends the results to the existing strings
     *
     * @param   string   $extension  The extension for which a language file should be loaded.
     * @param   string   $basePath   The basepath to use.
     * @param   string   $lang       The language to load, default null for the current language.
     * @param   boolean  $reload     Flag that will force a language to be reloaded if set to true.
     *
     * @return  boolean  True if the file has successfully loaded.
     *
     * @since   1.0
     */
    public function load($extension = 'joomla', $basePath = '', $lang = null, $reload = false)
    {
        $lang     = $lang ?: $this->lang;
        $basePath = $basePath ?: $this->basePath;

        $path = $this->helper->getLanguagePath($basePath, $lang);

        $internal = $extension == 'joomla' || $extension == '';
        $filename = $internal ? $lang : $lang . '.' . $extension;
        $filename = "$path/$filename.ini";

        if (isset($this->paths[$extension][$filename]) && !$reload) {
            // This file has already been tested for loading.
            return $this->paths[$extension][$filename];
        }

        // Load the language file
        return $this->loadLanguage($filename, $extension);
    }

    /**
     * Loads a language file.
     *
     * This method will not note the successful loading of a file - use load() instead.
     *
     * @param   string  $filename   The name of the file.
     * @param   string  $extension  The name of the extension.
     *
     * @return  boolean  True if new strings have been added to the language
     *
     * @see     Language::load()
     * @since   1.0
     */
    protected function loadLanguage($filename, $extension = 'unknown')
    {
        $this->counter++;

        $result  = false;

        if (file_exists($filename)) {
            $strings = $this->parse($filename);

            if (\count($strings)) {
                $this->catalogue->addMessages(array_replace($strings, $this->override));
                $result = true;
            }
        }

        // Record the result of loading the extension's file.
        if (!isset($this->paths[$extension])) {
            $this->paths[$extension] = [];
        }

        $this->paths[$extension][$filename] = $result;

        return $result;
    }

    /**
     * Parses a language file.
     *
     * @param   string  $filename  The name of the file.
     *
     * @return  array  The array of parsed strings.
     *
     * @since   1.0
     */
    protected function parse($filename)
    {
        // Capture hidden PHP errors from the parsing.
        if ($this->debug) {
            // See https://www.php.net/manual/en/reserved.variables.phperrormsg.php
            $php_errormsg = null;
            $trackErrors  = ini_get('track_errors');
            ini_set('track_errors', true);
        }

        try {
            $strings = $this->parserRegistry->get(pathinfo($filename, PATHINFO_EXTENSION))->loadFile($filename);
        } catch (\RuntimeException $exception) {
            // TODO - This shouldn't be absorbed
            $strings = [];
        }

        if ($this->debug) {
            // Restore error tracking to what it was before.
            ini_set('track_errors', $trackErrors);

            $this->debugFile($filename);
        }

        return \is_array($strings) ? $strings : [];
    }

    /**
     * Debugs a language file
     *
     * @param   string  $filename  Absolute path to the file to debug
     *
     * @return  integer  A count of the number of parsing errors
     *
     * @since   2.0
     */
    public function debugFile(string $filename): int
    {
        // Make sure our file actually exists
        if (!file_exists($filename)) {
            throw new \InvalidArgumentException(
                sprintf('Unable to locate file "%s" for debugging', $filename)
            );
        }

        // Initialise variables for manually parsing the file for common errors.
        $debug        = $this->setDebug(false);
        $php_errormsg = null;

        $parser = $this->parserRegistry->get(pathinfo($filename, PATHINFO_EXTENSION));

        if (!($parser instanceof DebugParserInterface)) {
            return 0;
        }

        $errors = $parser->debugFile($filename);

        // Check if we encountered any errors.
        if (\count($errors)) {
            $this->errorfiles[$filename] = $filename . ' - error(s) ' . implode(', ', $errors);
        } elseif ($php_errormsg) {
            // We didn't find any errors but there's probably a parse notice.
            $this->errorfiles['PHP' . $filename] = 'PHP parser errors -' . $php_errormsg;
        }

        $this->setDebug($debug);

        return \count($errors);
    }

    /**
     * Get a metadata language property.
     *
     * @param   string  $property  The name of the property.
     * @param   mixed   $default   The default value.
     *
     * @return  mixed  The value of the property.
     *
     * @since   1.0
     */
    public function get($property, $default = null)
    {
        return $this->metadata[$property] ?? $default;
    }

    /**
     * Get the base path for the instance.
     *
     * @return  string
     *
     * @since   2.0
     */
    public function getBasePath(): string
    {
        return $this->basePath;
    }

    /**
     * Determine who called Language or Text.
     *
     * @return  mixed  Caller information or null if unavailable
     *
     * @since   1.0
     */
    protected function getCallerInfo()
    {
        // Try to determine the source if none was provided
        if (!\function_exists('debug_backtrace')) {
            return;
        }

        $backtrace = debug_backtrace();
        $info      = [];

        // Search through the backtrace to our caller
        $continue = true;

        while ($continue && next($backtrace)) {
            $step  = current($backtrace);
            $class = @ $step['class'];

            // We're looking for something outside of language.php
            if ($class != __CLASS__ && $class != Text::class) {
                $info['function'] = @ $step['function'];
                $info['class']    = $class;
                $info['step']     = prev($backtrace);

                // Determine the file and name of the file
                $info['file'] = @ $step['file'];
                $info['line'] = @ $step['line'];

                $continue = false;
            }
        }

        return $info;
    }

    /**
     * Getter for Name.
     *
     * @return  string  Official name element of the language.
     *
     * @since   1.0
     */
    public function getName()
    {
        return $this->metadata['name'];
    }

    /**
     * Get a list of language files that have been loaded.
     *
     * @param   string  $extension  An optional extension name.
     *
     * @return  array
     *
     * @since   1.0
     */
    public function getPaths($extension = null)
    {
        if (isset($extension)) {
            return $this->paths[$extension] ?? null;
        }

        return $this->paths;
    }

    /**
     * Get a list of language files that are in error state.
     *
     * @return  array
     *
     * @since   1.0
     */
    public function getErrorFiles()
    {
        return $this->errorfiles;
    }

    /**
     * Getter for the language tag (as defined in RFC 3066)
     *
     * @return  string  The language tag.
     *
     * @since   1.0
     */
    public function getTag()
    {
        return $this->metadata['tag'];
    }

    /**
     * Get the RTL property.
     *
     * @return  boolean  True is it an RTL language.
     *
     * @since   1.0
     */
    public function isRtl()
    {
        return (bool) $this->metadata['rtl'];
    }

    /**
     * Set the Debug property.
     *
     * @param   boolean  $debug  The debug setting.
     *
     * @return  boolean  Previous value.
     *
     * @since   1.0
     */
    public function setDebug($debug)
    {
        $previous    = $this->debug;
        $this->debug = (bool) $debug;

        return $previous;
    }

    /**
     * Get the Debug property.
     *
     * @return  boolean  True is in debug mode.
     *
     * @since   1.0
     */
    public function getDebug()
    {
        return $this->debug;
    }

    /**
     * Get the default language code.
     *
     * @return  string  Language code.
     *
     * @since   1.0
     */
    public function getDefault()
    {
        return $this->default;
    }

    /**
     * Set the default language code.
     *
     * @param   string  $lang  The language code.
     *
     * @return  string  Previous value.
     *
     * @since   1.0
     */
    public function setDefault($lang)
    {
        $previous      = $this->default;
        $this->default = $lang;

        return $previous;
    }

    /**
     * Get the list of orphaned strings if being tracked.
     *
     * @return  array  Orphaned text.
     *
     * @since   1.0
     */
    public function getOrphans()
    {
        return $this->orphans;
    }

    /**
     * Get the list of used strings.
     *
     * Used strings are those strings requested and found either as a string or a constant.
     *
     * @return  array  Used strings.
     *
     * @since   1.0
     */
    public function getUsed()
    {
        return $this->used;
    }

    /**
     * Determines is a key exists.
     *
     * @param   string  $string  The key to check.
     *
     * @return  boolean  True, if the key exists.
     *
     * @since   1.0
     */
    public function hasKey($string)
    {
        return $this->catalogue->hasMessage($string);
    }

    /**
     * Returns a associative array holding the metadata.
     *
     * @param   string  $lang      The name of the language.
     * @param   string  $basePath  The filepath to the language folder.
     *
     * @return  mixed  If $lang exists return key/value pair with the language metadata, otherwise return NULL.
     *
     * @see     LanguageHelper::getMetadata()
     * @since   1.0
     * @deprecated  3.0  Use LanguageHelper::getMetadata() instead
     */
    public static function getMetadata($lang, $basePath)
    {
        trigger_deprecation(
            'joomla/language',
            '2.0.0',
            '%s() is deprecated and will be removed in 3.0, use %s::getMetadata() instead.',
            __METHOD__,
            LanguageHelper::class
        );

        return (new LanguageHelper())->getMetadata($lang, $basePath);
    }

    /**
     * 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.
     *
     * @see     LanguageHelper::getKnownLanguages()
     * @since   1.0
     * @deprecated  3.0  Use LanguageHelper::getKnownLanguages() instead
     */
    public static function getKnownLanguages($basePath = '')
    {
        trigger_deprecation(
            'joomla/language',
            '2.0.0',
            '%s() is deprecated and will be removed in 3.0, use %s::getKnownLanguages() instead.',
            __METHOD__,
            LanguageHelper::class
        );

        return (new LanguageHelper())->getKnownLanguages($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.
     *
     * @see     LanguageHelper::getLanguagePath()
     * @since   1.0
     * @deprecated  3.0  Use LanguageHelper::getLanguagePath() instead
     */
    public static function getLanguagePath($basePath = '', $language = '')
    {
        trigger_deprecation(
            'joomla/language',
            '2.0.0',
            '%s() is deprecated and will be removed in 3.0, use %s::getLanguagePath() instead.',
            __METHOD__,
            LanguageHelper::class
        );

        return (new LanguageHelper())->getLanguagePath($basePath, $language);
    }

    /**
     * Get the current language code.
     *
     * @return  string  The language code
     *
     * @since   1.0
     */
    public function getLanguage()
    {
        return $this->lang;
    }

    /**
     * Get the message catalogue for the language.
     *
     * @return  MessageCatalogue
     *
     * @since   2.0
     */
    public function getCatalogue(): MessageCatalogue
    {
        return $this->catalogue;
    }

    /**
     * Set the message catalogue for the language.
     *
     * @param   MessageCatalogue  $catalogue  The message catalogue to use.
     *
     * @return  void
     *
     * @since   2.0
     */
    public function setCatalogue(MessageCatalogue $catalogue): void
    {
        $this->catalogue = $catalogue;
    }

    /**
     * Get the language locale based on current language.
     *
     * @return  array  The locale according to the language.
     *
     * @since   1.0
     */
    public function getLocale()
    {
        if (!isset($this->locale)) {
            $locale = str_replace(' ', '', $this->metadata['locale'] ?? '');

            $this->locale = $locale ? explode(',', $locale) : false;
        }

        return $this->locale;
    }

    /**
     * Get the first day of the week for this language.
     *
     * @return  integer  The first day of the week according to the language
     *
     * @since   1.0
     */
    public function getFirstDay()
    {
        return (int) ($this->metadata['firstDay'] ?? 0);
    }

    /**
     * Get the weekends days for this language.
     *
     * @return  string  The weekend days of the week separated by a comma according to the language
     *
     * @since   2.0
     */
    public function getWeekEnd(): string
    {
        return $this->metadata['weekEnd'] ?? '0,6';
    }

    /**
     * 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.
     *
     * @see     LanguageHelper::parseLanguageFiles()
     * @since   1.0
     * @deprecated  3.0  Use LanguageHelper::parseLanguageFiles() instead
     */
    public static function parseLanguageFiles($dir = null)
    {
        trigger_deprecation(
            'joomla/language',
            '2.0.0',
            '%s() is deprecated and will be removed in 3.0, use %s::parseLanguageFiles() instead.',
            __METHOD__,
            LanguageHelper::class
        );

        return (new LanguageHelper())->parseLanguageFiles($dir);
    }

    /**
     * Parse XML file for language information.
     *
     * @param   string  $path  Path to the XML files.
     *
     * @return  mixed  Array holding the found metadata as a key => value pair or null on an invalid XML file
     *
     * @see     LanguageHelper::parseXMLLanguageFile()
     * @since   1.0
     * @deprecated  3.0  Use LanguageHelper::parseXMLLanguageFile() instead
     */
    public static function parseXmlLanguageFile($path)
    {
        trigger_deprecation(
            'joomla/language',
            '2.0.0',
            '%s() is deprecated and will be removed in 3.0, use %s::parseXmlLanguageFile() instead.',
            __METHOD__,
            LanguageHelper::class
        );

        return (new LanguageHelper())->parseXMLLanguageFile($path);
    }
}
