<?php

/**
 * User Settings plugin — action component.
 *
 * Provides three things:
 *
 *   1. A "Preferences" item in the user menu, placed just before "Update
 *      Profile" (via the MENU_ITEMS_ASSEMBLY event — template-independent).
 *
 *   2. A custom action, do=usersettings, claimed in ACTION_ACT_PREPROCESS and
 *      rendered in TPL_ACT_UNKNOWN. This is the documented way for a plugin to
 *      own a do= value: preventing the preprocess default makes DokuWiki route
 *      the action through dokuwiki\Action\Plugin, which fires TPL_ACT_UNKNOWN.
 *
 *   3. The settings page itself: a plain HTML form of every registered toggle,
 *      with Post/Redirect/Get handling that saves through the helper.
 */

// must be run within DokuWiki
if (!defined('DOKU_INC')) die();

use dokuwiki\Extension\ActionPlugin;
use dokuwiki\Extension\EventHandler;
use dokuwiki\Extension\Event;

class action_plugin_usersettings extends ActionPlugin
{
    /** the do= value this plugin owns */
    const ACTION = 'usersettings';

    /** GET parameter that carries the user's language to the (session-less) js.php request */
    const JS_LANG_PARAM = 'uslang';

    /**
     * The site-default language as it was *before* applyUserLang() overrode it
     * for this request, or null when no override happened. Captured so the
     * js.php URL builder can tell whether the user's choice actually differs
     * from the site default (by the time it runs, $conf['lang'] is already the
     * user's language).
     *
     * @var string|null
     */
    protected $siteDefaultLang = null;

    /**
     * Register event handlers.
     */
    public function register(EventHandler $controller)
    {
        $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'handleMenuAssembly');
        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handlePreprocess');
        $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'handleUnknown');

        // Register the built-in interface language toggle.
        $controller->register_hook(
            helper_plugin_usersettings::REGISTER_EVENT,
            'BEFORE',
            $this,
            'registerLangToggle'
        );

        // Apply the user's language choice as early as possible so that all
        // DokuWiki rendering — including TPL_ hooks further down the chain —
        // uses the right language strings.  ACTION_ACT_PREPROCESS fires before
        // any output is produced and before template rendering begins.
        $controller->register_hook(
            'ACTION_ACT_PREPROCESS',
            'BEFORE',
            $this,
            'applyUserLang',
            null,
            // run at priority -10 so we fire before handlePreprocess (0) and
            // before anything else that might read $conf['lang']
            -10
        );

        // js.php is its own request and runs with NOSESSION, so it has no
        // REMOTE_USER and ACTION_ACT_PREPROCESS never fires for it. We therefore
        // carry the user's language to js.php through the <script> URL: this hook
        // (fired during the normal, authenticated page request) appends
        // &uslang=<code> to the js.php src, which both signals the language to
        // js.php and makes the browser cache the bundle per language.
        $controller->register_hook(
            'TPL_METAHEADER_OUTPUT',
            'BEFORE',
            $this,
            'appendUserLangToJsUrl'
        );

        // Read the language back off the js.php URL (it survives NOSESSION) and
        // switch $conf['lang'] before js.php loads its strings. Without this the
        // JavaScript language bundle (LANG, LANG.plugins.*) always ships in the
        // SITE-default language. JS_SCRIPT_LIST is the one event js.php fires
        // before it builds its cache key and loads JS strings.
        $controller->register_hook(
            'JS_SCRIPT_LIST',
            'BEFORE',
            $this,
            'applyUserLangToScripts'
        );
    }

    /**
     * Load the storage/registration helper.
     *
     * @return helper_plugin_usersettings|null
     */
    protected function getHelper()
    {
        /** @var helper_plugin_usersettings|null $helper */
        $helper = plugin_load('helper', 'usersettings');
        return $helper;
    }

    // ---------------------------------------------------------------------
    //  1. The user-menu item
    // ---------------------------------------------------------------------

    /**
     * Insert the "Preferences" item into the user menu, just before the
     * "Update Profile" item.
     *
     * @param Event $event MENU_ITEMS_ASSEMBLY
     * @param mixed       $param
     */
    public function handleMenuAssembly(Event $event, $param)
    {
        if (!is_array($event->data) || ($event->data['view'] ?? '') !== 'user') {
            return;
        }

        try {
            $item = new \dokuwiki\plugin\usersettings\MenuItem();
        } catch (\RuntimeException $e) {
            // anonymous visitor, or the action is disabled — no menu item
            return;
        }

        if (!isset($event->data['items']) || !is_array($event->data['items'])) {
            return;
        }
        $items =& $event->data['items'];

        // find the Profile item; default to appending if it is not present
        $pos = count($items);
        foreach ($items as $i => $existing) {
            if ($existing instanceof \dokuwiki\Menu\Item\Profile) {
                $pos = $i;
                break;
            }
        }
        array_splice($items, $pos, 0, [$item]);
    }

    // ---------------------------------------------------------------------
    //  2. Claiming the custom action + handling the save
    // ---------------------------------------------------------------------

    /**
     * Claim do=usersettings and, on a form submission, save and redirect.
     *
     * @param Event $event ACTION_ACT_PREPROCESS
     * @param mixed       $param
     */
    public function handlePreprocess(Event $event, $param)
    {
        if ($event->data !== self::ACTION) {
            return;
        }

        // Preventing the default makes DokuWiki keep the action and route it
        // through dokuwiki\Action\Plugin, which will fire TPL_ACT_UNKNOWN.
        $event->preventDefault();
        $event->stopPropagation();

        global $INPUT, $ID;

        $user = $INPUT->server->str('REMOTE_USER');
        if ($user === '') {
            return; // anonymous — the rendered page shows a login notice
        }

        // not a save submission — nothing to do, the page will just render
        if (!$INPUT->post->bool('usersettings_save')) {
            return;
        }

        // CSRF protection; checkSecurityToken() shows its own error on failure
        if (!checkSecurityToken()) {
            return;
        }

        $ok = $this->saveSubmittedPreferences($user);
        msg($this->getLang($ok ? 'saved' : 'savefail'), $ok ? 1 : -1);

        // Post/Redirect/Get: a refresh must not re-submit the form
        send_redirect(wl($ID, ['do' => self::ACTION], true, '&'));
    }

    /**
     * Read the submitted toggle values for every registered toggle and store
     * them for the given user.
     *
     * Kept separate from handlePreprocess() so it carries no redirect and can
     * be exercised directly by tests. Checkboxes that are unchecked do not
     * appear in the POST data, so every registered toggle is read explicitly
     * rather than iterating whatever was submitted.
     *
     * @param string      $user   whose preferences are being written
     * @param string|null $actor  who is making the change; defaults to $user
     *                            (the admin component passes the admin here)
     * @return bool
     */
    public function saveSubmittedPreferences($user, $actor = null)
    {
        global $INPUT;

        if ($actor === null) {
            $actor = $user;
        }

        $helper = $this->getHelper();
        if ($helper === null) {
            return false;
        }

        $values = [];
        foreach ($helper->getRegisteredToggles() as $key => $def) {
            if ($def['type'] === 'checkbox') {
                $values[$key] = $INPUT->post->bool($key) ? 1 : 0;
            } else {
                $values[$key] = $INPUT->post->str($key);
            }
        }

        return $helper->setPreferences($values, $user, $actor);
    }

    // ---------------------------------------------------------------------
    //  3. Rendering the settings page
    // ---------------------------------------------------------------------

    /**
     * Render the settings page for do=usersettings.
     *
     * @param Event $event TPL_ACT_UNKNOWN
     * @param mixed       $param
     */
    public function handleUnknown(Event $event, $param)
    {
        if ($event->data !== self::ACTION) {
            return;
        }
        $event->preventDefault();
        $event->stopPropagation();

        echo $this->renderSettingsPage();
    }

    /**
     * Build the HTML of the settings page.
     *
     * @return string
     */
    public function renderSettingsPage()
    {
        global $INPUT, $ID;

        $user = $INPUT->server->str('REMOTE_USER');

        $html  = '<div class="plugin_usersettings">';
        $html .= '<h1>' . hsc($this->getLang('heading')) . '</h1>';

        if ($user === '') {
            $html .= '<p>' . hsc($this->getLang('nologin')) . '</p>';
            return $html . '</div>';
        }

        $helper  = $this->getHelper();
        $toggles = $helper ? $helper->getRegisteredToggles() : [];

        if (empty($toggles)) {
            $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>';
            return $html . '</div>';
        }

        $html .= '<p class="us-intro">' . hsc($this->getLang('intro')) . '</p>';

        $action = wl($ID, ['do' => self::ACTION], false, '&amp;');
        $html  .= '<form method="post" action="' . $action . '" class="us-form">';
        $html  .= formSecurityToken(false);

        foreach ($toggles as $key => $def) {
            $html .= $this->renderToggleRow($def, $helper->getPreference($key, $user));
        }

        $html .= '<div class="us-actions">';
        $html .= '<button type="submit" name="usersettings_save" value="1" class="button">'
               . hsc($this->getLang('save')) . '</button>';
        $html .= '</div>';
        $html .= '</form>';

        return $html . '</div>';
    }

    // ---------------------------------------------------------------------
    //  Built-in: interface language toggle
    // ---------------------------------------------------------------------

    /**
     * Contribute the "Interface language" select to the usersettings registry.
     *
     * The option list is built by scanning DOKU_INC/inc/lang/ for sub-
     * directories that contain a lang.php file — the same source the
     * Configuration Manager uses for its own language drop-down.  The scan
     * result is sorted alphabetically by language code; the site default is
     * used as the toggle's default value so the toggle appears pre-selected
     * correctly for users who have never changed it.
     *
     * @param Event $event PLUGIN_USERSETTINGS_REGISTER
     * @param mixed       $param
     */
    public function registerLangToggle(Event $event, $param)
    {
        global $conf;

        $options = $this->getAvailableLanguages();
        if (empty($options)) {
            return; // nothing to register if we cannot list languages
        }

        $siteDefault = $conf['lang'] ?? 'en';
        if (!array_key_exists($siteDefault, $options)) {
            $siteDefault = array_key_first($options);
        }

        $event->data[] = [
            'key'     => 'lang',
            'label'   => $this->getLang('lang_label'),
            'desc'    => $this->getLang('lang_desc'),
            'type'    => 'select',
            'options' => $options,
            'default' => $siteDefault,
            'plugin'  => 'usersettings',
        ];
    }

    /**
     * Build the [code => display name] map of all installed DokuWiki interface
     * languages by scanning inc/lang/.  The display name is the language's own
     * native name (endonym), falling back to the bare code for any language not
     * in the built-in map.
     *
     * @return array  [langCode => endonym]  sorted by language code
     */
    protected function getAvailableLanguages()
    {
        $pattern = DOKU_INC . 'inc/lang/*/lang.php';
        $files   = glob($pattern);
        if ($files === false || empty($files)) {
            return [];
        }

        $langs = [];
        foreach ($files as $file) {
            $code = basename(dirname($file)); // e.g. "de" from ".../inc/lang/de/lang.php"
            if ($code === '' || $code === '.' || $code === '..') {
                continue;
            }
            $langs[$code] = $this->languageName($code);
        }

        ksort($langs, SORT_STRING);
        return $langs;
    }

    /**
     * Return the native name (endonym) for a language code.
     * Falls back to the bare code for languages not in the built-in map.
     *
     * @param string $code ISO language code as used by DokuWiki
     * @return string
     */
    protected function languageName($code)
    {
        $names = [
            'af'          => 'Afrikaans',
            'ar'          => 'العربية',
            'az'          => 'Azərbaycan',
            'be'          => 'Беларуская',
            'bg'          => 'Български',
            'bn'          => 'বাংলা',
            'br'          => 'Brezhoneg',
            'ca'          => 'Català',
            'ca-valencia' => 'Català (Valencià)',
            'ckb'         => 'کوردی سۆرانی',
            'cs'          => 'Čeština',
            'cy'          => 'Cymraeg',
            'da'          => 'Dansk',
            'de'          => 'Deutsch',
            'de-informal' => 'Deutsch (informell)',
            'el'          => 'Ελληνικά',
            'en'          => 'English',
            'eo'          => 'Esperanto',
            'es'          => 'Español',
            'et'          => 'Eesti',
            'eu'          => 'Euskara',
            'fa'          => 'فارسی',
            'fi'          => 'Suomi',
            'fo'          => 'Føroyskt',
            'fr'          => 'Français',
            'fy'          => 'Frysk',
            'gl'          => 'Galego',
            'he'          => 'עברית',
            'hi'          => 'हिन्दी',
            'hr'          => 'Hrvatski',
            'hu'          => 'Magyar',
            'hu-formal'   => 'Magyar (magázó)',
            'hy'          => 'Հայերեն',
            'ia'          => 'Interlingua',
            'id'          => 'Bahasa Indonesia',
            'id-ni'       => 'Bahasa Indonesia (NTT)',
            'is'          => 'Íslenska',
            'it'          => 'Italiano',
            'ja'          => '日本語',
            'ka'          => 'ქართული',
            'kk'          => 'Қазақша',
            'km'          => 'ភាសាខ្មែរ',
            'kn'          => 'ಕನ್ನಡ',
            'ko'          => '한국어',
            'ku'          => 'Kurdî',
            'la'          => 'Latina',
            'lb'          => 'Lëtzebuergesch',
            'lt'          => 'Lietuvių',
            'lv'          => 'Latviešu',
            'mg'          => 'Malagasy',
            'mk'          => 'Македонски',
            'ml'          => 'മലയാളം',
            'mr'          => 'मराठी',
            'ms'          => 'Bahasa Melayu',
            'my'          => 'မြန်မာ',
            'nan'         => '閩南語',
            'nb'          => 'Norsk bokmål',
            'ne'          => 'नेपाली',
            'nl'          => 'Nederlands',
            'nn'          => 'Nynorsk',
            'no'          => 'Norsk',
            'oc'          => 'Occitan',
            'pl'          => 'Polski',
            'pt'          => 'Português',
            'pt-br'       => 'Português brasileiro',
            'ro'          => 'Română',
            'ru'          => 'Русский',
            'si'          => 'සිංහල',
            'sk'          => 'Slovenčina',
            'sl'          => 'Slovenščina',
            'sq'          => 'Shqip',
            'sr'          => 'Српски',
            'sv'          => 'Svenska',
            'sw'          => 'Kiswahili',
            'ta'          => 'தமிழ்',
            'te'          => 'తెలుగు',
            'th'          => 'ภาษาไทย',
            'tr'          => 'Türkçe',
            'uk'          => 'Українська',
            'ur'          => 'اردو',
            'uz'          => 'Oʻzbekcha',
            'vi'          => 'Tiếng Việt',
            'zh'          => '中文 (简体)',
            'zh-tw'       => '中文 (繁體)',
        ];

        return $names[$code] ?? $code;
    }

    /**
     * Apply the logged-in user's preferred interface language, overriding the
     * site-wide $conf['lang'] before any rendering takes place.
     *
     * DokuWiki loads language strings lazily (via getLang() / $lang global
     * reloads triggered by calls to init_lang()), so changing $conf['lang']
     * here — in the earliest ACTION_ACT_PREPROCESS handler — is sufficient
     * to affect all subsequent output, including this plugin's own chrome.
     *
     * We read the stored record directly via getRecord() rather than routing
     * through getPreference().  getPreference() fires PLUGIN_USERSETTINGS_REGISTER
     * (and the full inc/lang/ glob) on every logged-in request before the
     * language is known.  Reading the raw record avoids that overhead and,
     * crucially, means the toggle registry is built *after* $conf['lang'] has
     * been updated — so toggle labels resolve in the user's chosen language.
     *
     * No-op for anonymous visitors or when the user has not chosen a language
     * that differs from the site default.
     *
     * @param Event $event ACTION_ACT_PREPROCESS
     * @param mixed  $param
     */
    public function applyUserLang(Event $event, $param)
    {
        global $conf;

        $preferred = $this->resolvePreferredLang();
        if ($preferred === null) {
            return; // anonymous, no/invalid preference, or already correct
        }

        // Remember the real site default before we override it, so the js.php
        // URL builder (which runs after this, when $conf['lang'] is already the
        // user's choice) can still tell the two apart.
        $this->siteDefaultLang = $conf['lang'];

        $conf['lang'] = $preferred;

        // Re-initialise the global $lang array so immediately-following
        // getLang() calls within this request pick up the new language.
        init_lang($preferred);
    }

    /**
     * Append the user's interface language to the lib/exe/js.php <script> URL.
     *
     * js.php runs with NOSESSION (no REMOTE_USER), so it cannot look the user's
     * preference up itself — we pass it on the URL instead. The query parameter
     * also makes the browser treat each language as a distinct resource, so the
     * cached English bundle is not reused after a language switch.
     *
     * Runs in the normal page request, where the session (and the language
     * override from applyUserLang) is available. No-op when the user has no
     * preference, or it equals the site default.
     *
     * @param Event $event TPL_METAHEADER_OUTPUT ($event->data is the head array, by ref)
     * @param mixed $param
     */
    public function appendUserLangToJsUrl(Event $event, $param)
    {
        global $conf;

        $preferred = $this->getValidatedStoredLang();
        if ($preferred === null) {
            return;
        }

        // $conf['lang'] may already be the user's language (applyUserLang ran),
        // so compare against the captured site default when we have it.
        $default = $this->siteDefaultLang ?? $conf['lang'];
        if ($preferred === $default) {
            return;
        }

        if (empty($event->data['script']) || !is_array($event->data['script'])) {
            return;
        }

        foreach ($event->data['script'] as $i => $script) {
            if (!is_array($script) || empty($script['src'])) {
                continue;
            }
            if (strpos($script['src'], 'lib/exe/js.php') === false) {
                continue;
            }
            $event->data['script'][$i]['src'] .=
                '&' . self::JS_LANG_PARAM . '=' . rawurlencode($preferred);
        }
    }

    /**
     * Apply the user's interface language to the on-the-fly JavaScript bundle
     * (lib/exe/js.php).
     *
     * js.php is its own request, runs with NOSESSION (no REMOTE_USER), and never
     * fires ACTION_ACT_PREPROCESS, so applyUserLang() does not reach it and we
     * cannot look the user up here. The language is instead read from the
     * &uslang= URL parameter that appendUserLangToJsUrl() put on the <script>
     * src; it survives NOSESSION because it travels in the URL, not the session.
     *
     * Two things must happen here, both before js.php proceeds:
     *
     *   1. Switch $conf['lang'] (+ init_lang) so js_pluginstrings() and friends
     *      read the user's language.
     *   2. Repoint the language-specific datepicker entry already present in
     *      the file list. js.php keys its output cache on
     *      md5(serialize($files)); that datepicker path is the only
     *      language-dependent member, so without rewriting it two users with
     *      different languages would collide on a single cached bundle. js.php
     *      skips non-existent files, so this is safe even for a language that
     *      ships no datepicker translation.
     *
     * The parameter is user-controllable, so it is validated to a real
     * inc/lang/ directory (lowercase [a-z0-9-]) before use.
     *
     * @param Event $event JS_SCRIPT_LIST ($event->data is the file list, by ref)
     * @param mixed $param
     */
    public function applyUserLangToScripts(Event $event, $param)
    {
        global $conf, $INPUT;

        $preferred = $INPUT->str(self::JS_LANG_PARAM);
        if (!$this->isValidLangCode($preferred) || $preferred === $conf['lang']) {
            return;
        }

        $old = $conf['lang'];

        if (is_array($event->data)) {
            $needle      = 'inc/lang/' . $old . '/jquery.ui.datepicker.js';
            $replacement = 'inc/lang/' . $preferred . '/jquery.ui.datepicker.js';
            foreach ($event->data as $i => $file) {
                if (is_string($file) && strpos($file, $needle) !== false) {
                    $event->data[$i] = str_replace($needle, $replacement, $file);
                }
            }
        }

        $conf['lang'] = $preferred;
        init_lang($preferred);
    }

    /**
     * Resolve the logged-in user's stored, validated interface-language
     * preference, or null when there is none to apply.
     *
     * Returns null for anonymous visitors, when no preference is stored, when
     * the stored value is malformed or names a missing inc/lang/ directory, or
     * when it already matches the active $conf['lang']. Reads the raw stored
     * record via getRecord() so it does NOT fire PLUGIN_USERSETTINGS_REGISTER
     * (which would glob inc/lang/ on every request).
     *
     * @return string|null validated language code, or null for "leave as-is"
     */
    protected function resolvePreferredLang()
    {
        global $conf;

        $preferred = $this->getValidatedStoredLang();
        if ($preferred === null || $preferred === $conf['lang']) {
            return null; // no preference, invalid, or already correct
        }

        return $preferred;
    }

    /**
     * The logged-in user's stored, validated interface-language preference,
     * irrespective of the currently active $conf['lang'].
     *
     * Unlike resolvePreferredLang() this does NOT short-circuit when the stored
     * value already equals $conf['lang'] — the js.php URL builder needs the raw
     * preference because applyUserLang() may have already switched $conf['lang']
     * to it. Reads the raw stored record via getRecord() so it does NOT fire
     * PLUGIN_USERSETTINGS_REGISTER (which would glob inc/lang/ on every request).
     *
     * @return string|null validated language code, or null when there is none
     */
    protected function getValidatedStoredLang()
    {
        global $INPUT;

        $user = $INPUT->server->str('REMOTE_USER');
        if ($user === '') {
            return null; // anonymous — use the site default
        }

        $helper = $this->getHelper();
        if ($helper === null) {
            return null;
        }

        // Read the raw stored record — does not fire PLUGIN_USERSETTINGS_REGISTER.
        $record    = $helper->getRecord('lang', $user);
        $preferred = (is_array($record) && isset($record['value'])) ? (string) $record['value'] : null;

        return $this->isValidLangCode($preferred) ? $preferred : null;
    }

    /**
     * Whether a string is a usable interface-language code: lowercase
     * [a-z0-9-] (defence-in-depth against path traversal) naming an existing
     * inc/lang/ directory (so a stale or bogus code never breaks the page).
     *
     * @param string|null $code
     * @return bool
     */
    protected function isValidLangCode($code)
    {
        if ($code === null || $code === '') {
            return false;
        }
        if (!preg_match('/^[a-z0-9-]+$/', $code)) {
            return false;
        }
        return is_dir(DOKU_INC . 'inc/lang/' . $code);
    }

    // ---------------------------------------------------------------------
    //  Form rendering (shared between action and admin)
    // ---------------------------------------------------------------------

    /**
     * Render one toggle as a form row. Public so the admin component can
     * reuse it for its per-user edit form.
     *
     * @param array $def    a normalised toggle definition
     * @param mixed $value  the user's effective value for this toggle
     * @return string
     */
    public function renderToggleRow(array $def, $value)
    {
        $key = hsc($def['key']);

        if ($def['type'] === 'select') {
            $id   = 'us__' . $key;
            $html = '<div class="us-row us-row-select">';
            $html .= '<label class="us-label" for="' . $id . '">';
            $html .= '<span class="us-name">' . hsc($def['label']) . '</span>';
            $html .= '<select name="' . $key . '" id="' . $id . '">';
            foreach ($def['options'] as $optValue => $optLabel) {
                $selected = ((string) $optValue === (string) $value) ? ' selected="selected"' : '';
                $html .= '<option value="' . hsc((string) $optValue) . '"' . $selected . '>'
                       . hsc((string) $optLabel) . '</option>';
            }
            $html .= '</select>';
            $html .= '</label>';
        } else {
            $checked = empty($value) ? '' : ' checked="checked"';
            $html  = '<div class="us-row us-row-checkbox">';
            $html .= '<label class="us-label">';
            $html .= '<input type="checkbox" name="' . $key . '" value="1"' . $checked . ' />';
            $html .= '<span class="us-name">' . hsc($def['label']) . '</span>';
            $html .= '</label>';
        }

        if ($def['desc'] !== '') {
            $html .= '<div class="us-desc">' . hsc($def['desc']) . '</div>';
        }

        return $html . '</div>';
    }
}
