<?php

/**
 * User Settings plugin — admin component.
 *
 * Provides an admin-only overview of every user's preferences as a flat,
 * sortable table: one row per (user x setting), with the columns
 *   Display name | Setting | Value | Changed by | Changed at.
 * A row's value is the user's explicit choice, or the toggle's default
 * (marked as such) when they never set one. A filter narrows the table to a
 * single setting; the table never grows wider as more toggles are added.
 *
 * Clicking a display name opens a per-user edit form (Model A+: an admin may
 * change anyone's preferences). Such a change is stored with the admin as the
 * recorded actor, and the user can still change it back themselves later.
 */

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

use dokuwiki\Extension\AdminPlugin;
use dokuwiki\Utf8\PhpString;

class admin_plugin_usersettings extends AdminPlugin
{
    /** @var string[] sort key => row field used for that sort */
    protected $sortFields = [
        'login'     => 'user',
        'name'      => 'display_name',
        'mail'      => 'mail',
        'grps'      => 'grps',
        'setting'   => 'setting_label',
        'value'     => 'value_display',
        'changedby' => 'changed_by_display',
        'changedat' => 'changed_at',
    ];

    // ---------------------------------------------------------------------
    //  Admin plugin metadata
    // ---------------------------------------------------------------------

    /** Only administrators may see other users' preferences. */
    public function forAdminOnly()
    {
        return true;
    }

    /** Position in the admin menu. */
    public function getMenuSort()
    {
        return 1000;
    }

    /** Admin menu label — distinct from the user menu's "Preferences". */
    public function getMenuText($language)
    {
        return $this->getLang('admin_menu');
    }

    // ---------------------------------------------------------------------
    //  Component access
    // ---------------------------------------------------------------------

    /** @return helper_plugin_usersettings|null */
    protected function getHelper()
    {
        return plugin_load('helper', 'usersettings');
    }

    /** @return action_plugin_usersettings|null */
    protected function getActionPlugin()
    {
        return plugin_load('action', 'usersettings');
    }

    // ---------------------------------------------------------------------
    //  Request handling
    // ---------------------------------------------------------------------

    /**
     * Handle a submitted per-user edit form.
     *
     * Runs only for admins (DokuWiki's admin dispatcher enforces
     * forAdminOnly() before this is called). Uses Post/Redirect/Get.
     */
    public function handle()
    {
        global $INPUT, $ID;

        if (!$INPUT->post->bool('usersettings_adminsave')) {
            return;
        }
        if (!checkSecurityToken()) {
            return;
        }

        $this->processAdminSave();

        // Post/Redirect/Get back to the overview
        send_redirect(wl($ID, ['do' => 'admin', 'page' => 'usersettings'], true, '&'));
    }

    /**
     * Validate the target user and store the submitted preferences for them,
     * recording the acting admin as the actor. Kept redirect-free so it can
     * be tested directly.
     *
     * @return bool
     */
    public function processAdminSave()
    {
        global $INPUT, $auth;

        $target = $INPUT->post->str('edituser');
        $admin  = $INPUT->server->str('REMOTE_USER');

        $userData = ($auth !== null) ? $auth->getUserData($target) : false;
        if ($userData === false) {
            msg($this->getLang('badidentuser'), -1);
            return false;
        }

        $action = $this->getActionPlugin();
        $ok = ($action !== null) && $action->saveSubmittedPreferences($target, $admin);

        $name = (($userData['name'] ?? '') !== '') ? $userData['name'] : $target;
        msg(
            sprintf($this->getLang($ok ? 'adminsaved' : 'adminsavefail'), hsc($name)),
            $ok ? 1 : -1
        );
        return $ok;
    }

    // ---------------------------------------------------------------------
    //  Output
    // ---------------------------------------------------------------------

    /**
     * Render either the overview table or, when an edituser parameter is
     * present, the per-user edit form.
     */
    public function html()
    {
        global $INPUT;

        $edituser = $INPUT->get->str('edituser');
        if ($edituser !== '') {
            echo $this->renderEditForm($edituser);
        } else {
            echo $this->renderTable();
        }
    }

    // ---- overview table --------------------------------------------------

    /**
     * Build the rows of the overview: one per (user x toggle).
     *
     * @param array $users   [username => userdata] as from $auth->retrieveUsers()
     * @param array $toggles registered toggle definitions
     * @return array list of row arrays
     */
    public function buildRows(array $users, array $toggles)
    {
        $helper = $this->getHelper();
        if ($helper === null) {
            return [];
        }

        $rows = [];
        foreach ($users as $username => $userData) {
            $displayName = (!empty($userData['name'])) ? $userData['name'] : $username;
            $mail        = $userData['mail'] ?? '';
            $grps        = isset($userData['grps']) ? implode(', ', (array) $userData['grps']) : '';
            $stored      = $helper->loadUserData($username);

            foreach ($toggles as $key => $def) {
                if (isset($stored[$key]) && array_key_exists('value', $stored[$key])) {
                    $value     = $stored[$key]['value'];
                    $changedBy = $stored[$key]['changed_by'] ?? '';
                    $changedAt = (int) ($stored[$key]['changed_at'] ?? 0);
                    $isDefault = false;
                } else {
                    $value     = $def['default'];
                    $changedBy = '';
                    $changedAt = 0;
                    $isDefault = true;
                }

                $rows[] = [
                    'user'               => $username,
                    'display_name'       => $displayName,
                    'mail'               => $mail,
                    'grps'               => $grps,
                    'setting_key'        => $key,
                    'setting_label'      => $def['label'],
                    'value_display'      => $this->displayValue($def, $value),
                    'is_default'         => $isDefault,
                    'changed_by_display' => $isDefault ? '' : $this->resolveActor($changedBy, $users),
                    'changed_at'         => $changedAt,
                ];
            }
        }
        return $rows;
    }

    /**
     * Sort overview rows by the given column and direction.
     *
     * @param array  $rows
     * @param string $sort one of the keys of $this->sortFields
     * @param string $dir  'asc' or 'desc'
     * @return array
     */
    public function sortRows(array $rows, $sort, $dir)
    {
        $field = $this->sortFields[$sort] ?? 'display_name';

        usort($rows, function ($a, $b) use ($field) {
            if ($field === 'changed_at') {
                return $a[$field] <=> $b[$field];
            }
            return strcasecmp((string) $a[$field], (string) $b[$field]);
        });

        if ($dir === 'desc') {
            $rows = array_reverse($rows);
        }
        return $rows;
    }

    /**
     * Human-readable value of a toggle: On/Off for a checkbox, the option
     * label for a select.
     *
     * @param array $def
     * @param mixed $value
     * @return string
     */
    public function displayValue(array $def, $value)
    {
        if ($def['type'] === 'select') {
            if (isset($def['options'][$value])) {
                return (string) $def['options'][$value];
            }
            return (string) $value; // stored value no longer a defined option
        }
        return $this->getLang(empty($value) ? 'val_off' : 'val_on');
    }

    /**
     * Resolve an actor username to a display name, falling back to the raw
     * username when the actor is not (or no longer) a known user.
     *
     * @param string $actor
     * @param array  $users [username => userdata]
     * @return string
     */
    protected function resolveActor($actor, array $users)
    {
        if ($actor === '') {
            return '';
        }
        if (isset($users[$actor]) && !empty($users[$actor]['name'])) {
            return $users[$actor]['name'];
        }
        return $actor;
    }

    /**
     * Render the overview table: sortable headers, a per-column text-filter
     * row (plus the existing setting drop-down), the rows for the current
     * page, and a numbered pager.
     *
     * @return string
     */
    protected function renderTable()
    {
        global $INPUT, $auth, $ID;

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

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

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

        $users = ($auth !== null) ? $auth->retrieveUsers(0, 0) : [];
        if (empty($users)) {
            return $html . '<p>' . hsc($this->getLang('nousers')) . '</p></div>';
        }

        $showMail = (bool) $this->getConf('show_mail');
        $showGrps = (bool) $this->getConf('show_grps');
        $perPage  = (int) $this->getConf('entries_per_page');

        // visible columns, in display order
        $cols = ['login', 'name'];
        if ($showMail) {
            $cols[] = 'mail';
        }
        if ($showGrps) {
            $cols[] = 'grps';
        }
        $cols = array_merge($cols, ['setting', 'value', 'changedby', 'changedat']);

        // col => row field for the text-filterable columns ("Setting" has its
        // own drop-down; "Changed at" is not filterable)
        $filterMap = $this->filterFieldMap($cols);

        // request parameters (sort links and the filter form are GET)
        $sort = $INPUT->get->str('sort', 'name');
        if (!isset($this->sortFields[$sort])) {
            $sort = 'name';
        }
        if (($sort === 'mail' && !$showMail) || ($sort === 'grps' && !$showGrps)) {
            $sort = 'name';
        }
        $dir = ($INPUT->get->str('dir') === 'desc') ? 'desc' : 'asc';

        $setFilter = $INPUT->get->str('filter');
        if ($setFilter !== '' && !isset($toggles[$setFilter])) {
            $setFilter = '';
        }
        $qfilters = $this->activeFilters(array_keys($filterMap));

        $html .= '<p>' . hsc($this->getLang('admin_intro')) . '</p>';

        // build rows, narrow by the setting drop-down, then the text filters,
        // then sort — all before paging so the counts and page numbers match
        $rows = $this->buildRows($users, $toggles);
        if ($setFilter !== '') {
            $rows = array_values(array_filter($rows, static function ($r) use ($setFilter) {
                return $r['setting_key'] === $setFilter;
            }));
        }
        $rows  = $this->applyFilters($rows, $qfilters, $filterMap);
        $rows  = $this->sortRows($rows, $sort, $dir);
        $total = count($rows);

        [$pageRows, $page, $totalPages, $from, $to] = $this->paginate($rows, $perPage);

        $labels = [
            'login'     => $this->getLang('th_login'),
            'name'      => $this->getLang('th_name'),
            'mail'      => $this->getLang('th_mail'),
            'grps'      => $this->getLang('th_grps'),
            'setting'   => $this->getLang('th_setting'),
            'value'     => $this->getLang('th_value'),
            'changedby' => $this->getLang('th_changedby'),
            'changedat' => $this->getLang('th_changedat'),
        ];

        // GET form so the filters combine with the sort links and bookmark
        // cleanly; the action URL's query string is dropped on submit, so the
        // standing parameters travel as hidden fields.
        $html .= '<form class="us-filter-form" method="get" action="' . DOKU_BASE . DOKU_SCRIPT . '">';
        $html .= '<input type="hidden" name="id" value="' . hsc($ID) . '" />';
        $html .= '<input type="hidden" name="do" value="admin" />';
        $html .= '<input type="hidden" name="page" value="usersettings" />';
        $html .= '<input type="hidden" name="sort" value="' . hsc($sort) . '" />';
        $html .= '<input type="hidden" name="dir" value="' . hsc($dir) . '" />';
        $html .= '<input type="hidden" name="pg" value="1" />'; // a new search lands on page 1

        $html .= '<div class="table">';
        $html .= '<table class="inline plugin_usersettings_table">';
        $html .= '<thead><tr>';
        foreach ($cols as $c) {
            $html .= $this->sortHeader($labels[$c], $c, $sort, $dir, $setFilter, $qfilters);
        }
        $html .= '</tr>';
        $html .= $this->renderFilterRow($cols, $filterMap, $qfilters, $toggles, $setFilter, $sort, $dir);
        $html .= '</thead><tbody>';

        if ($total === 0) {
            $html .= '<tr><td colspan="' . count($cols) . '" class="us-none">'
                   . hsc($this->getLang('none')) . '</td></tr>';
        } else {
            foreach ($pageRows as $row) {
                $rowClass = $row['is_default'] ? ' class="us-default-row"' : '';
                $html .= '<tr' . $rowClass . '>';
                foreach ($cols as $c) {
                    $html .= $this->bodyCell($c, $row);
                }
                $html .= '</tr>';
            }
        }

        $html .= '</tbody></table></div>';
        $html .= '</form>';

        $html .= $this->renderPager($page, $totalPages, $sort, $dir, $setFilter, $qfilters);

        if ($total > 0) {
            $html .= '<p class="us-count">'
                   . hsc(sprintf($this->getLang('shown'), $from, $to, $total)) . '</p>';
        }

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

    /**
     * Render one body cell for a column.
     *
     * @param string $c   column key
     * @param array  $row
     * @return string
     */
    protected function bodyCell($c, array $row)
    {
        switch ($c) {
            case 'login':
                return '<td>' . hsc($row['user']) . '</td>';
            case 'name':
                $editUrl = $this->pageURL(['edituser' => $row['user']]);
                return '<td><a href="' . $editUrl . '">' . hsc($row['display_name']) . '</a></td>';
            case 'mail':
                return '<td>' . hsc($row['mail']) . '</td>';
            case 'grps':
                return '<td>' . hsc($row['grps']) . '</td>';
            case 'setting':
                return '<td>' . hsc($row['setting_label']) . '</td>';
            case 'value':
                return '<td>' . hsc($row['value_display']) . '</td>';
            case 'changedby':
                return '<td>' . ($row['is_default']
                    ? '<span class="us-default-mark">' . hsc($this->getLang('bydefault')) . '</span>'
                    : hsc($row['changed_by_display'])) . '</td>';
            case 'changedat':
                return '<td>' . ($row['changed_at'] > 0 ? hsc(dformat($row['changed_at'])) : '&mdash;') . '</td>';
            default:
                return '<td></td>';
        }
    }

    // ---- filtering -------------------------------------------------------

    /**
     * Map of text-filterable column => row field, limited to the visible
     * columns. "Setting" (drop-down) and "Changed at" are deliberately absent.
     *
     * @param string[] $cols visible columns
     * @return array
     */
    protected function filterFieldMap(array $cols)
    {
        $all = [
            'login'     => 'user',
            'name'      => 'display_name',
            'mail'      => 'mail',
            'grps'      => 'grps',
            'value'     => 'value_display',
            'changedby' => 'changed_by_display',
        ];
        $map = [];
        foreach ($cols as $c) {
            if (isset($all[$c])) {
                $map[$c] = $all[$c];
            }
        }
        return $map;
    }

    /**
     * Read the active text filters from the request (the q[] array), keeping
     * only the given columns and dropping blanks.
     *
     * @param string[] $cols filterable column keys
     * @return array [column => trimmed term]
     */
    protected function activeFilters(array $cols)
    {
        global $INPUT;
        $raw = $INPUT->arr('q');
        $out = [];
        foreach ($cols as $c) {
            if (isset($raw[$c]) && is_string($raw[$c])) {
                $term = trim($raw[$c]);
                if ($term !== '') {
                    $out[$c] = $term;
                }
            }
        }
        return $out;
    }

    /**
     * Keep only rows matching every active text filter (substring,
     * case-insensitive).
     *
     * @param array $rows
     * @param array $qfilters [column => term]
     * @param array $map      [column => row field]
     * @return array
     */
    protected function applyFilters(array $rows, array $qfilters, array $map)
    {
        if ($qfilters === []) {
            return $rows;
        }
        return array_values(array_filter($rows, function ($row) use ($qfilters, $map) {
            foreach ($qfilters as $col => $term) {
                $field = $map[$col] ?? null;
                if ($field === null) {
                    continue;
                }
                if (!$this->matches($row[$field] ?? '', $term)) {
                    return false;
                }
            }
            return true;
        }));
    }

    /**
     * Case-insensitive UTF-8 substring test.
     *
     * @param string $haystack
     * @param string $needle
     * @return bool
     */
    protected function matches($haystack, $needle)
    {
        if ($needle === '') {
            return true;
        }
        $h = PhpString::strtolower((string) $haystack);
        $n = PhpString::strtolower((string) $needle);
        return PhpString::strpos($h, $n) !== false;
    }

    // ---- pagination ------------------------------------------------------

    /**
     * Slice the rows for the current page.
     *
     * @param array $rows    filtered + sorted rows
     * @param int   $perPage rows per page; <= 0 means "all on one page"
     * @return array [pageRows, page, totalPages, from, to] — from/to are 1-based
     *               row numbers of the slice (0 when there are no rows)
     */
    protected function paginate(array $rows, $perPage)
    {
        global $INPUT;
        $total = count($rows);

        if ($perPage <= 0) {
            return [$rows, 1, 1, $total > 0 ? 1 : 0, $total];
        }

        $totalPages = max(1, (int) ceil($total / $perPage));
        $page = $INPUT->int('pg', 1);
        if ($page < 1) {
            $page = 1;
        }
        if ($page > $totalPages) {
            $page = $totalPages;
        }

        $offset = ($page - 1) * $perPage;
        $slice  = array_slice($rows, $offset, $perPage);
        $from   = $total > 0 ? $offset + 1 : 0;
        $to     = min($total, $offset + $perPage);

        return [$slice, $page, $totalPages, $from, $to];
    }

    // ---- link + header + filter-row + pager helpers ----------------------

    /**
     * The standing query parameters every in-table link must carry: sort,
     * direction, the setting drop-down and the active text filters.
     *
     * @param string $sort
     * @param string $dir
     * @param string $setFilter
     * @param array  $qfilters
     * @return array
     */
    protected function standingParams($sort, $dir, $setFilter, array $qfilters)
    {
        $params = ['sort' => $sort, 'dir' => $dir];
        if ($setFilter !== '') {
            $params['filter'] = $setFilter;
        }
        if ($qfilters !== []) {
            $params['q'] = $qfilters;
        }
        return $params;
    }

    /**
     * Build a URL back to this admin page with the given query parameters.
     *
     * @param array $params
     * @return string HTML-attribute-safe URL
     */
    protected function tableURL(array $params)
    {
        global $ID;
        $base = ['do' => 'admin', 'page' => 'usersettings'];
        return wl($ID, array_merge($base, $params), false, '&amp;');
    }

    /**
     * Render one sortable column header. Clicking the active column flips the
     * direction; the drop-down filter and text filters are preserved and the
     * page resets to 1.
     *
     * @param string $label
     * @param string $col       sort key for this column
     * @param string $sort      currently active sort key
     * @param string $dir       currently active direction
     * @param string $setFilter currently selected setting key
     * @param array  $qfilters  active text filters
     * @return string
     */
    protected function sortHeader($label, $col, $sort, $dir, $setFilter, array $qfilters)
    {
        // clicking the active column flips direction; others start ascending
        $newDir = ($sort === $col && $dir === 'asc') ? 'desc' : 'asc';
        $arrow  = '';
        if ($sort === $col) {
            $arrow = ($dir === 'asc') ? " \u{25B2}" : " \u{25BC}";
        }

        $url = $this->tableURL(array_merge(
            $this->standingParams($sort, $dir, $setFilter, $qfilters),
            ['sort' => $col, 'dir' => $newDir]
        ));
        return '<th><a href="' . $url . '">' . hsc($label) . $arrow . '</a></th>';
    }

    /**
     * The per-column filter row: a text input under each text-filterable
     * column, the existing setting drop-down under "Setting", and the
     * Search/Clear controls under the (non-filterable) "Changed at" column.
     *
     * @param string[] $cols      visible columns in order
     * @param array    $filterMap col => row field for text-filterable columns
     * @param array    $qfilters  active text filters
     * @param array    $toggles   registered toggles (for the drop-down)
     * @param string   $setFilter currently selected setting key
     * @param string   $sort
     * @param string   $dir
     * @return string
     */
    protected function renderFilterRow(array $cols, array $filterMap, array $qfilters, array $toggles, $setFilter, $sort, $dir)
    {
        $html = '<tr class="us-filterrow">';
        foreach ($cols as $c) {
            if ($c === 'setting') {
                $html .= '<td><select name="filter" title="' . hsc($this->getLang('filter_label')) . '">';
                $html .= '<option value="">' . hsc($this->getLang('filter_all')) . '</option>';
                foreach ($toggles as $key => $def) {
                    $selected = ($setFilter === $key) ? ' selected="selected"' : '';
                    $html .= '<option value="' . hsc($key) . '"' . $selected . '>'
                           . hsc($def['label']) . '</option>';
                }
                $html .= '</select></td>';
            } elseif ($c === 'changedat') {
                $html .= '<td class="us-filteractions">';
                $html .= '<button type="submit" class="button">'
                       . hsc($this->getLang('filter_search')) . '</button>';
                if ($setFilter !== '' || $qfilters !== []) {
                    $clear = $this->tableURL(['sort' => $sort, 'dir' => $dir]);
                    $html .= ' <a class="us-clear" href="' . $clear . '">'
                           . hsc($this->getLang('filter_clear')) . '</a>';
                }
                $html .= '</td>';
            } elseif (isset($filterMap[$c])) {
                $val = isset($qfilters[$c]) ? hsc($qfilters[$c]) : '';
                $html .= '<td><input type="text" name="q[' . hsc($c) . ']" class="edit" value="'
                       . $val . '" /></td>';
            } else {
                $html .= '<td></td>';
            }
        }
        return $html . '</tr>';
    }

    /**
     * Render the numbered pager: « prev  1 … 4 [5] 6 … 20  next ». Returns the
     * empty string when there is only one page.
     *
     * @param int    $page
     * @param int    $totalPages
     * @param string $sort
     * @param string $dir
     * @param string $setFilter
     * @param array  $qfilters
     * @return string
     */
    protected function renderPager($page, $totalPages, $sort, $dir, $setFilter, array $qfilters)
    {
        if ($totalPages <= 1) {
            return '';
        }

        $html = '<nav class="us-pager" aria-label="' . hsc($this->getLang('pager_label')) . '">';

        if ($page > 1) {
            $html .= $this->pagerLink($page - 1, $sort, $dir, $setFilter, $qfilters, '&#8249;', 'pager_prev');
        } else {
            $html .= '<span class="pager_btn pager_disabled">&#8249;</span>';
        }

        foreach ($this->pageWindow($page, $totalPages) as $p) {
            if ($p === 0) {
                $html .= '<span class="pager_gap">&#8230;</span>';
            } elseif ($p === $page) {
                $html .= '<span class="pager_cur">' . $p . '</span>';
            } else {
                $html .= $this->pagerLink($p, $sort, $dir, $setFilter, $qfilters, (string) $p, '');
            }
        }

        if ($page < $totalPages) {
            $html .= $this->pagerLink($page + 1, $sort, $dir, $setFilter, $qfilters, '&#8250;', 'pager_next');
        } else {
            $html .= '<span class="pager_btn pager_disabled">&#8250;</span>';
        }

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

    /**
     * One pager link (number or arrow), preserving sort + filters.
     *
     * @param int    $p        target page
     * @param string $sort
     * @param string $dir
     * @param string $setFilter
     * @param array  $qfilters
     * @param string $text     already-safe link text (number or entity)
     * @param string $titleKey lang key for the title attribute, or '' for none
     * @return string
     */
    protected function pagerLink($p, $sort, $dir, $setFilter, array $qfilters, $text, $titleKey)
    {
        $url = $this->tableURL(array_merge(
            $this->standingParams($sort, $dir, $setFilter, $qfilters),
            ['pg' => $p]
        ));
        $title = ($titleKey !== '') ? ' title="' . hsc($this->getLang($titleKey)) . '"' : '';
        return '<a class="pager_btn" href="' . $url . '"' . $title . '>' . $text . '</a>';
    }

    /**
     * Page numbers around the current page, 0 marking an elided gap; always
     * includes the first and last page.
     *
     * @param int $page
     * @param int $totalPages
     * @return int[]
     */
    protected function pageWindow($page, $totalPages)
    {
        $window = 2;
        $keep   = [];
        for ($i = 1; $i <= $totalPages; $i++) {
            if ($i === 1 || $i === $totalPages || ($i >= $page - $window && $i <= $page + $window)) {
                $keep[] = $i;
            }
        }

        $out  = [];
        $prev = 0;
        foreach ($keep as $p) {
            if ($prev && ($p - $prev) > 1) {
                $out[] = 0; // gap marker
            }
            $out[] = $p;
            $prev  = $p;
        }
        return $out;
    }

    // ---- per-user edit form ---------------------------------------------

    /**
     * Render the edit form for one user's preferences.
     *
     * @param string $user
     * @return string
     */
    protected function renderEditForm($user)
    {
        global $auth, $ID;

        $html = '<div class="plugin_usersettings_admin plugin_usersettings">';

        $userData = ($auth !== null) ? $auth->getUserData($user) : false;
        if ($userData === false) {
            $html .= '<h1>' . hsc($this->getLang('admin_heading')) . '</h1>';
            $html .= '<p>' . hsc($this->getLang('badidentuser')) . '</p>';
            $html .= '<p><a href="' . $this->pageURL() . '">'
                   . hsc($this->getLang('edit_back')) . '</a></p>';
            return $html . '</div>';
        }

        $displayName = (($userData['name'] ?? '') !== '') ? $userData['name'] : $user;
        $html .= '<h1>' . hsc(sprintf($this->getLang('edit_heading'), $displayName)) . '</h1>';

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

        if (empty($toggles) || $action === null) {
            $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>';
            $html .= '<p><a href="' . $this->pageURL() . '">'
                   . hsc($this->getLang('edit_back')) . '</a></p>';
            return $html . '</div>';
        }

        $formAction = wl($ID, ['do' => 'admin', 'page' => 'usersettings'], false, '&amp;');
        $html .= '<form method="post" action="' . $formAction . '" class="us-form">';
        $html .= formSecurityToken(false);
        $html .= '<input type="hidden" name="edituser" value="' . hsc($user) . '" />';
        $html .= '<input type="hidden" name="usersettings_adminsave" value="1" />';

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

        $html .= '<div class="us-actions">';
        $html .= '<button type="submit" class="button">'
               . hsc($this->getLang('save')) . '</button> ';
        $html .= '<a href="' . $this->pageURL() . '" class="us-back">'
               . hsc($this->getLang('edit_back')) . '</a>';
        $html .= '</div>';
        $html .= '</form>';

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

    // ---- helpers ---------------------------------------------------------

    /**
     * Build a URL back to this admin page with the given extra parameters.
     *
     * @param array $params
     * @return string  HTML-attribute-safe URL
     */
    protected function pageURL(array $params = [])
    {
        global $ID;
        $base = ['do' => 'admin', 'page' => 'usersettings'];
        return wl($ID, array_merge($base, $params), false, '&amp;');
    }
}
