<?php

/**
 * @package Dotclear
 * @subpackage Backend
 *
 * @copyright Olivier Meunier & Association Dotclear
 * @copyright AGPL-3.0
 */
declare(strict_types=1);

namespace Dotclear\Core\Backend;

use Autoloader;
use Dotclear\App;
use Dotclear\Helper\Date;
use Dotclear\Helper\File\Files;
use Dotclear\Helper\File\Path;
use Dotclear\Helper\Html\Form\Caption;
use Dotclear\Helper\Html\Form\Checkbox;
use Dotclear\Helper\Html\Form\Details;
use Dotclear\Helper\Html\Form\Div;
use Dotclear\Helper\Html\Form\Fieldset;
use Dotclear\Helper\Html\Form\File;
use Dotclear\Helper\Html\Form\Form;
use Dotclear\Helper\Html\Form\Hidden;
use Dotclear\Helper\Html\Form\Img;
use Dotclear\Helper\Html\Form\Input;
use Dotclear\Helper\Html\Form\Label;
use Dotclear\Helper\Html\Form\Legend;
use Dotclear\Helper\Html\Form\Li;
use Dotclear\Helper\Html\Form\Link;
use Dotclear\Helper\Html\Form\None;
use Dotclear\Helper\Html\Form\Note;
use Dotclear\Helper\Html\Form\Para;
use Dotclear\Helper\Html\Form\Password;
use Dotclear\Helper\Html\Form\Set;
use Dotclear\Helper\Html\Form\Span;
use Dotclear\Helper\Html\Form\Strong;
use Dotclear\Helper\Html\Form\Submit;
use Dotclear\Helper\Html\Form\Summary;
use Dotclear\Helper\Html\Form\Table;
use Dotclear\Helper\Html\Form\Td;
use Dotclear\Helper\Html\Form\Text;
use Dotclear\Helper\Html\Form\Th;
use Dotclear\Helper\Html\Form\Tr;
use Dotclear\Helper\Html\Form\Ul;
use Dotclear\Helper\Html\Form\Url;
use Dotclear\Helper\Html\Html;
use Dotclear\Helper\Network\Http;
use Dotclear\Helper\Text as Txt;
use Dotclear\Interface\Module\ModulesInterface;
use Dotclear\Module\ModuleDefine;
use Dotclear\Module\Store;
use Exception;

/**
 * Helper for admin list of plugins.
 *
 * Provides an object to parse XML feed of modules from a repository.
 *
 * @since 2.6
 */
class ModulesList
{
    /**
     * Store instance
     */
    public readonly Store $store;

    /**
     * Work with multiple root directories
     */
    public static bool $allow_multi_install = false;

    /**
     * List of modules distributed with Dotclear
     *
     * @deprecated  since 2.26, use Modules::getDefine($id)->distributed instead
     *
     * @var        string[]    $distributed_modules
     */
    public static array $distributed_modules = [];

    /**
     * Current list ID
     */
    protected string $list_id = 'unknown';

    /**
     * Current modules defines
     *
     * @var        ModuleDefine[]  $defines
     */
    protected array $defines = [];

    /**
     * Module define to configure
     */
    protected ?ModuleDefine $config_define = null;

    /**
     * Module class to configure
     */
    protected string $config_class = '';

    /**
     * Module path to configure
     */
    protected string $config_file = '';

    /**
     * Module configuration page content
     */
    protected string $config_content = '';

    /**
     * Modules root directories
     */
    protected ?string $path;

    /**
     * Indicate if modules root directory is writable
     */
    protected bool $path_writable = false;

    /**
     * Directory pattern to work on
     */
    protected string $path_pattern = '';

    /**
     * Page URL
     */
    protected string $page_url;

    /**
     * Page tab
     */
    protected string $page_tab = '';

    /**
     * Page redirection
     */
    protected string $page_redir = '';

    /**
     * Index list
     */
    public static string $nav_indexes = 'abcdefghijklmnopqrstuvwxyz0123456789';

    /**
     * Index list with special index
     *
     * @var        string[]     $nav_list
     */
    protected array $nav_list;

    /**
     * Text for other special index
     */
    protected string $nav_special;

    /**
     * Field used to sort modules
     */
    protected string $sort_field = 'sname';

    /**
     * Ascendant sort order?
     */
    protected bool $sort_asc = true;

    /**
     * Constructor.
     *
     * Note that this creates Store instance.
     *
     * @param    ModulesInterface   $modules            Modules instance
     * @param    string             $modules_root       Modules root directories
     * @param    null|string        $xml_url            URL of modules feed from repository
     * @param    null|bool          $force              Force query repository
     * @param    bool               $use_cache_only     Check for updates using cache only (no HTTP requests)
     */
    public function __construct(
        /*
         * Stack of known modules
         */
        public ModulesInterface $modules,
        string $modules_root,
        ?string $xml_url,
        ?bool $force = false,
        bool $use_cache_only = true,
    ) {
        if ($force === true) {
            $use_cache_only = false;
        }

        $this->store = new Store($this->modules, $xml_url, $force, use_cache_only: $use_cache_only);

        $this->page_url = App::backend()->url()->get('admin.plugins');

        $this->setPath($modules_root);
        $this->setIndex(__('other'));
    }

    /**
     * Begin a new list.
     *
     * @param    string    $id        New list ID
     *
     * @return    ModulesList self instance
     */
    public function setList(string $id): ModulesList
    {
        $this->defines  = [];
        $this->page_tab = '';
        $this->list_id  = $id;

        return $this;
    }

    /**
     * Get list ID.
     */
    public function getList(): string
    {
        return $this->list_id;
    }

    /// @name Modules root directory methods
    ///@{

    /**
     * Set path info.
     *
     * @param    string    $root        Modules root directories
     *
     * @return    ModulesList self instance
     */
    protected function setPath(string $root): ModulesList
    {
        $paths = explode(PATH_SEPARATOR, $root);
        $path  = array_pop($paths);
        unset($paths);

        $this->path = $path;
        if (is_dir($path) && is_writable($path)) {
            $this->path_writable = true;
            $this->path_pattern  = preg_quote($path, '!');
        }

        return $this;
    }

    /**
     * Get modules root directories.
     *
     * @return    null|string    directory to work on
     */
    public function getPath(): ?string
    {
        return $this->path;
    }

    /**
     * Check if modules root directory is writable.
     *
     * @return    bool  True if directory is writable
     */
    public function isWritablePath(): bool
    {
        return $this->path_writable;
    }

    /**
     * Check if root directory of a module is deletable.
     *
     * @param    string    $root        Module root directory
     *
     * @return    bool  True if directory is delatable
     */
    public function isDeletablePath(string $root): bool
    {
        return $this->path_writable
        && (preg_match('!^' . $this->path_pattern . '!', $root) || App::config()->devMode())
        && App::auth()->isSuperAdmin();
    }

    ///@}

    /// @name Page methods
    ///@{

    /**
     * Set page base URL.
     *
     * @param    string    $url        Page base URL
     *
     * @return    ModulesList self instance
     */
    public function setURL(string $url): ModulesList
    {
        $this->page_url = $url;

        return $this;
    }

    /**
     * Get page URL.
     *
     * @param    string|array<mixed>    $queries    Additionnal query string
     * @param    bool                   $with_tab   Add current tab to URL end
     * @param    string|null            $force_tab  Tab to use in URL instead of $this->page_tab
     * @param    bool                   $parametric Use &amp; to separate queries' items if true, else use &
     *
     * @return   string Clean page URL
     */
    public function getURL($queries = '', bool $with_tab = true, ?string $force_tab = null, bool $parametric = true): string
    {
        $query = '';
        if (!empty($queries)) {
            // Cope with queries
            $query = str_contains($this->page_url, '?') ? ($parametric ? '&amp;' : '&') : '?';
            $query .= is_array($queries) ? http_build_query($queries, '', ($parametric ? null : '&')) : $queries;
        }

        $tab = '';
        if ($with_tab && ($this->page_tab !== '' || $force_tab !== null && $force_tab !== '')) {
            // Cope with tab (as hash in URL)
            $tab = '#' . ($force_tab ?: $this->page_tab);
        }

        return $this->page_url . $query . $tab;
    }

    /**
     * Set page tab.
     *
     * @param    string    $tab        Page tab
     *
     * @return    ModulesList self instance
     */
    public function setTab(string $tab): ModulesList
    {
        $this->page_tab = $tab;

        return $this;
    }

    /**
     * Get page tab.
     *
     * @return  string  Page tab
     */
    public function getTab(): string
    {
        return $this->page_tab;
    }

    /**
     * Set page redirection.
     *
     * @param    string    $default        Default redirection
     *
     * @return    ModulesList self instance
     */
    public function setRedir(string $default = ''): ModulesList
    {
        $this->page_redir = empty($_REQUEST['redir']) || !is_string($_REQUEST['redir']) ? $default : $_REQUEST['redir'];

        return $this;
    }

    /**
     * Get page redirection.
     *
     * @return  string  Page redirection
     */
    public function getRedir(): string
    {
        return $this->page_redir === '' ? $this->getURL() : $this->page_redir;
    }

    ///@}

    /// @name Search methods
    ///@{

    /**
     * Get search query.
     */
    public function getSearch(): ?string
    {
        $query = isset($_REQUEST['m_search']) && is_string($query = $_REQUEST['m_search']) ? trim($query) : '';

        return strlen($query) >= 2 ? $query : null;
    }

    /**
     * Display searh form.
     *
     * @return    ModulesList self instance
     */
    public function displaySearch(): ModulesList
    {
        $query = $this->getSearch();

        if ($this->defines === [] && $query === null) {
            return $this;
        }

        echo (new Div())
            ->class('modules-search')
            ->items([
                (new Form(['modules-search-form']))
                    ->method('get')
                    ->action($this->getURL())
                    ->fields([
                        (new Para())
                            ->class('form-buttons')
                            ->items([
                                (new Input('m_search'))
                                    ->size(30)
                                    ->maxlength(255)
                                    ->value(Html::escapeHTML($query))
                                    ->label(new Label(__('Search in repository:'), Label::IL_TF)),
                                (new Submit(['modules-search-submit'], __('OK'))),
                                (new Hidden(['process'], is_a($this, ThemesList::class) ? 'BlogTheme' : 'Plugins')),
                                $query ? (new Link())->href($this->getURL())->class('button')->text(__('Reset search')) : (new None()),
                            ]),
                        (new Note())
                            ->class('form-note')
                            ->text(__('Search is allowed on multiple terms longer than 2 chars, terms must be separated by space.')),
                    ]),
                $query ?
                    (new Note())
                        ->class('message')
                        ->text(sprintf(
                            __('Found one result for search "%2$s":', 'Found %1$d results for search "%2$s":', count($this->defines)),
                            count($this->defines),
                            Html::escapeHTML($query)
                        )) :
                    (new None()),
            ])
        ->render();

        return $this;
    }

    ///@}

    /// @name Navigation menu methods
    ///@{

    /**
     * Set navigation special index.
     *
     * @param     string     $str   Index
     *
     * @return    ModulesList self instance
     */
    public function setIndex(string $str): ModulesList
    {
        $this->nav_special = $str;
        $this->nav_list    = [...str_split(self::$nav_indexes), ...[$this->nav_special]];

        return $this;
    }

    /**
     * Get index from query.
     *
     * @return  string  Query index or default one
     */
    public function getIndex(): string
    {
        return isset($_REQUEST['m_nav']) && is_string($_REQUEST['m_nav']) && in_array($_REQUEST['m_nav'], $this->nav_list) ? $_REQUEST['m_nav'] : $this->nav_list[0];
    }

    /**
     * Display navigation by index menu.
     *
     * @return    ModulesList self instance
     */
    public function displayIndex(): ModulesList
    {
        if ($this->defines === [] || $this->getSearch() !== null) {
            return $this;
        }

        # Fetch modules required field
        $indexes = [];
        foreach ($this->defines as $define) {
            $check = is_string($check = $define->get($this->sort_field)) ? $check : '';
            if ($check === '') {
                continue;
            }

            $char = substr($check, 0, 1);
            if (!in_array($char, $this->nav_list)) {
                $char = $this->nav_special;
            }

            $indexes[$char] ??= 0;
            $indexes[$char]++;
        }

        $buttons = [];
        foreach ($this->nav_list as $char) {
            # Selected letter
            if ($this->getIndex() == $char) {
                $buttons[] = (new Li())
                    ->class('active')
                    ->title(__('current selection'))
                    ->items([
                        (new Strong($char)),
                    ]);
            }
            # Letter having modules
            elseif (isset($indexes[$char]) && $indexes[$char] !== 0) {
                $title     = sprintf(__('One result', '%d results', $indexes[$char]), $indexes[$char]);
                $buttons[] = (new Li())
                    ->class('btn')
                    ->title($title)
                    ->items([
                        (new Link())
                            ->href($this->getURL('m_nav=' . $char))
                            ->title($title)
                            ->text($char),
                    ]);
            }
            # Letter without modules
            else {
                $buttons[] = (new Li())
                    ->class(['btn', 'no-link'])
                    ->title(__('no results'))
                    ->text($char);
            }
        }

        # Parse navigation menu
        echo (new Div())
            ->class('pager')
            ->items([
                (new Para())
                    ->items([
                        (new Text(null, __('Browse index:'))),
                    ]),
                (new Ul())
                    ->items($buttons),
            ])
        ->render();

        return $this;
    }

    ///@}

    /// @name Sort methods
    ///@{

    /**
     * Set default sort field.
     *
     * @param      string                 $field  The field
     * @param      bool                   $asc    The ascending
     *
     * @return     ModulesList     self instance
     */
    public function setSort(string $field, bool $asc = true): ModulesList
    {
        $this->sort_field = $field;
        $this->sort_asc   = $asc;

        return $this;
    }

    /**
     * Get sort field from query.
     *
     * @return    string    Query sort field or default one
     */
    public function getSort(): string
    {
        return empty($_REQUEST['m_sort']) || !is_string($_REQUEST['m_sort']) ? $this->sort_field : $_REQUEST['m_sort'];
    }

    /**
     * Display sort field form. (not implemented)
     *
     * @todo    Implement ModulesList::displaySort method
     *
     * @return  ModulesList self instance
     */
    public function displaySort(): ModulesList
    {
        return $this;
    }

    ///@}

    /// @name Modules methods
    ///@{

    /**
     * Set modules defines and sanitize them.
     *
     * @param   array<ModuleDefine>   $defines
     *
     * @return    ModulesList self instance
     */
    public function setDefines(array $defines): ModulesList
    {
        $this->defines = [];

        foreach ($defines as $define) {
            /* @phpstan-ignore instanceof.alwaysTrue */
            if (!($define instanceof ModuleDefine)) {
                continue;
            }

            self::fillSanitizeModule($define);
            $this->defines[] = $define;
        }

        return $this;
    }

    /**
     * Get modules defines currently set.
     *
     * @return    array<ModuleDefine>        Array of modules
     */
    public function getDefines(): array
    {
        return $this->defines;
    }

    /**
     * Set modules and sanitize them.
     *
     * @deprecated  since 2.26, use self::setDefines() instead
     *
     * @param   array<string, mixed>   $modules
     *
     * @return    ModulesList self instance
     */
    public function setModules(array $modules): ModulesList
    {
        App::deprecated()->set('adminModulesList::setDefines()', '2.26');

        $defines = [];
        foreach ($modules as $id => $module) {
            if (is_array($module)) {
                $define = new ModuleDefine($id);
                foreach ($module as $k => $v) {
                    if (is_string($k)) {
                        $define->set($k, $v);
                    }
                }

                $defines[] = $define;
            }
        }

        return $this->setDefines($defines);
    }

    /**
     * Get modules currently set.
     *
     * @deprecated  since 2.26, use self::getDefines() instead
     *
     * @return    array<string, array<string,mixed>>        Array of modules
     */
    public function getModules(): array
    {
        App::deprecated()->set('adminModulesList::getDefines()', '2.26');

        $res = [];
        foreach ($this->defines as $define) {
            $res[$define->getId()] = $define->dump();
        }

        return $res;
    }

    /**
     * Sanitize a module.
     *
     * This clean infos of a module by adding default keys
     * and clean some of them, sanitize module can safely
     * be used in lists.
     *
     * @param      ModuleDefine         $define The module definition
     * @param      array<string,mixed>  $module  The module
     */
    public static function fillSanitizeModule(ModuleDefine $define, array $module = []): void
    {
        foreach ($module as $k => $v) {
            $define->set($k, $v);
        }

        $id = $define->getId();

        $name  = is_string($name = $define->get('name')) ? $name : '';
        $label = is_string($label = $define->get('label')) ? $label : '';

        if ($label === '') {
            // Fallback to name or id if name is not set
            $label = $name ?: $id;
        }

        if ($name === '') {
            // Fallback to label
            $name = $label;
        }

        $define
            ->set('sid', self::sanitizeString($id))
            ->set('label', $label)
            ->set('name', $name)
            ->set('sname', self::sanitizeString(strtolower(Txt::removeDiacritics($name))));
    }

    /**
     * Sanitize a module (static version).
     *
     * This clean infos of a module by adding default keys
     * and clean some of them, sanitize module can safely
     * be used in lists.
     *
     * Warning: this static method will not fill module dependencies
     *
     * @deprecated  since 2.26, use self::fillSanitizeModule() instead
     *
     * @param      string               $id      The identifier
     * @param      array<string,mixed>  $module  The module
     *
     * @return   array<string,mixed>  Array of the module informations
     */
    public static function sanitizeModule(string $id, array $module): array
    {
        App::deprecated()->set('adminModulesList::fillSanitizeModule()', '2.26');

        $define = new ModuleDefine($id);
        self::fillSanitizeModule($define, $module);

        return $define->dump();
    }

    /**
     * Sanitize a module (dynamic version).
     *
     * This clean infos of a module by adding default keys
     * and clean some of them, sanitize module can safely
     * be used in lists.
     *
     * @deprecated  since 2.26, use self::fillSanitizeModule() instead
     *
     * @param      string                   $id      The identifier
     * @param      array<string,mixed>      $module  The module
     *
     * @return   array<string,mixed>  Array of the module informations
     */
    public function doSanitizeModule(string $id, array $module): array
    {
        App::deprecated()->set('adminModulesList::fillSanitizeModule()', '2.26');

        $define = $this->modules->getDefine($id);
        self::fillSanitizeModule($define, $module);

        return $define->dump();
    }

    /**
     * Check if a module is part of the distribution.
     *
     * @deprecated  since 2.26, use Modules::getDefine($id)->distributed instead
     *
     * @param    string    $id        Module root directory
     *
     * @return   bool  True if module is part of the distribution
     */
    public static function isDistributedModule(string $id): bool
    {
        App::deprecated()->set('Modules::getDefine($id)->distributed', '2.26');

        return in_array($id, self::$distributed_modules);
    }

    /**
     * Sort modules list by specific field.
     *
     * @deprecated  since 2.26, use something like uasort($defines, fn ($a, $b) => $a->get($field) <=> $b->get($field)); instead
     *
     * @param    array<string, array<string, mixed>>    $modules      Array of modules
     * @param    string                                 $field        Field to sort from
     * @param    bool                                   $asc          Sort asc if true, else decs
     *
     * @return   array<int|string, array<string, mixed>>  Array of sorted modules
     */
    public static function sortModules(array $modules, string $field, bool $asc = true): array
    {
        App::deprecated()->set('uasort()', '2.26');
        $origin = [];
        $sorter = [];
        $final  = [];

        foreach ($modules as $module) {
            $origin[] = $module;
            $sorter[] = $module[$field] ?? $field;
        }

        array_multisort($sorter, $asc ? SORT_ASC : SORT_DESC, $origin);

        foreach ($origin as $module) {
            if (is_string($module['id'])) {
                $final[$module['id']] = $module;
            }
        }

        return $final;
    }

    /**
     * Display list of modules.
     *
     * @param    array<string>      $cols         List of columns (module field) to display
     * @param    array<string>      $actions      List of predefined actions to show on form
     * @param    bool               $nav_limit    Limit list to previously selected index
     *
     * @return    ModulesList self instance
     */
    public function displayModules(array $cols = ['name', 'version', 'desc'], array $actions = [], bool $nav_limit = false): ModulesList
    {
        $rows    = [];
        $headers = [];

        if (in_array('name', $cols)) {
            $colspan = 1;
            if (in_array('checkbox', $cols)) {
                $colspan++;
            }

            if (in_array('icon', $cols)) {
                $colspan++;
            }

            $headers[] = (new Th())
                ->class(['first', 'nowrap'])
                ->text(__('Name'));
            if ($colspan > 1) {
                current($headers)->colspan($colspan);
            }
        }

        if (in_array('score', $cols) && $this->getSearch() !== null && App::config()->debugMode()) {
            $headers[] = (new Th())
                ->class('nowrap')
                ->text(__('Score'));
        }

        if (in_array('version', $cols)) {
            $headers[] = (new Th())
                ->class(['nowrap', 'count'])
                ->scope('col')
                ->text(__('Version'));
        }

        if (in_array('current_version', $cols)) {
            $headers[] = (new Th())
                ->class(['nowrap', 'count'])
                ->scope('col')
                ->text(__('Current version'));
        }

        if (in_array('desc', $cols)) {
            $headers[] = (new Th())
                ->class(['nowrap', 'module-desc'])
                ->scope('col')
                ->text(__('Details'));
        }

        if (in_array('repository', $cols) && App::config()->allowRepositories()) {
            $headers[] = (new Th())
                ->class(['nowrap', 'count'])
                ->scope('col')
                ->text(__('Repository'));
        }

        if (in_array('distrib', $cols)) {
            $headers[] = (new Th());
            if (!in_array('desc', $cols)) {
                current($headers)->class('maximal');
            }
        }

        if ($actions !== [] && App::auth()->isSuperAdmin()) {
            $headers[] = (new Th())
                ->class(['nowrap', 'minimal'])
                ->text(__('Action'));
        }

        $rows[] = (new Tr())->cols($headers);

        $sort_field = $this->getSort();

        # Sort modules by $sort_field (default sname)
        if ($this->getSearch() === null) {
            uasort($this->defines, fn ($a, $b): int => $a->get($sort_field) <=> $b->get($sort_field));
        }

        $count = 0;
        foreach ($this->defines as $define) {
            $data = [];
            $id   = $define->getId();

            # Show only requested modules
            if ($nav_limit && $this->getSearch() === null) {
                $check = is_string($check = $define->get($sort_field)) ? $check : '';
                if ($check === '') {
                    continue;
                }

                $char = substr($check, 0, 1);
                if (!in_array($char, $this->nav_list)) {
                    $char = $this->nav_special;
                }

                if ($this->getIndex() !== $char) {
                    continue;
                }
            }

            $root = is_string($root = $define->get('root')) ? $root : '';
            $name = is_string($name = $define->get('name')) ? $name : '';

            $git = (App::config()->devMode() || App::config()->debugMode()) && file_exists($root . '/.git');

            $tds = 0;

            if (in_array('checkbox', $cols)) {
                $tds++;
                $data[] = (new Td())
                    ->class(['module-icon', 'nowrap'])
                    ->items([
                        (new Checkbox(['modules[' . $count . ']', Html::escapeHTML($this->list_id) . '_modules_' . Html::escapeHTML($id)]))
                            ->value(Html::escapeHTML($id)),
                    ]);
            }

            if (in_array('icon', $cols)) {
                $tds++;
                $default_icon = false;

                if (file_exists($root . DIRECTORY_SEPARATOR . 'icon.svg')) {
                    $icon = App::backend()->page()->getPF($id . '/icon.svg');
                } elseif (file_exists($root . DIRECTORY_SEPARATOR . 'icon.png')) {
                    $icon = App::backend()->page()->getPF($id . '/icon.png');
                } else {
                    $icon         = 'images/module.svg';
                    $default_icon = true;
                }

                if (file_exists($root . DIRECTORY_SEPARATOR . 'icon-dark.svg')) {
                    $icon = [$icon, App::backend()->page()->getPF($id . '/icon-dark.svg')];
                } elseif (file_exists($root . DIRECTORY_SEPARATOR . 'icon-dark.png')) {
                    $icon = [$icon, App::backend()->page()->getPF($id . '/icon-dark.png')];
                } elseif ($default_icon) {
                    $icon = [$icon, 'images/module-dark.svg'];
                }

                $data[] = (new Td())
                    ->class(['module-icon', 'nowrap'])
                    ->text(App::backend()->helper()->adminIcon($icon, false, Html::escapeHTML($id), Html::escapeHTML($id)));
            }

            $tds++;
            $data_items = [];
            $name       = Html::escapeHTML($name) . ($id !== $name ? sprintf(__(' (%s)'), $id) : '');

            if (in_array('checkbox', $cols)) {
                if (in_array('expander', $cols)) {
                    $data_items[] = (new Text(null, $name));
                } else {
                    $data_items[] = (new Label($name))
                        ->for(Html::escapeHTML($this->list_id) . '_modules_' . Html::escapeHTML($id));
                }
            } else {
                $data_items[] = (new Text(null, $name));
                $data_items[] = (new Hidden(['modules[' . $count . ']'], Html::escapeHTML($id)));
            }

            $data[] = (new Th())
                ->class(['module-name', 'nowrap'])
                ->items($data_items);

            # Display score only for debug purpose
            if (in_array('score', $cols) && $this->getSearch() !== null && App::config()->debugMode()) {
                $score = is_numeric($score = $define->get('score')) ? (int) $score : 0;

                $tds++;
                $data[] = (new Td())
                    ->class(['module-version', 'nowrap', 'count'])
                    ->items([
                        (new Span((string) $score))
                            ->class('debug'),
                    ]);
            }

            if (in_array('version', $cols)) {
                $version = is_string($version = $define->get('version')) ? $version : '';

                $tds++;
                $data[] = (new Td())
                    ->class(['module-version', 'nowrap', 'count'])
                    ->text(Html::escapeHTML($version));
            }

            if (in_array('current_version', $cols)) {
                $current_version = is_string($current_version = $define->get('current_version')) ? $current_version : '';

                $tds++;
                $data[] = (new Td())
                    ->class(['module-current-version', 'nowrap', 'count'])
                    ->text(Html::escapeHTML($current_version));
            }

            if (in_array('desc', $cols)) {
                $state = is_numeric($state = $define->get('state')) ? (int) $state : 0;
                $desc  = is_string($desc = $define->get('desc')) ? $desc : '';

                $tds++;
                $infos = [];
                if (!empty($define->getUsing()) && $state === ModuleDefine::STATE_ENABLED) {
                    $infos[] = (new Para())
                        ->class('module-implies')
                        ->items([
                            (new Span(sprintf(
                                __('This module cannot be disabled nor deleted, since the following modules are also enabled : %s.'),
                                implode(', ', $define->getUsing())
                            )))->class('info'),
                        ]);
                }

                if (!empty($define->getMissing())) {
                    $reasons = [];
                    foreach ($define->getMissing() as $reason) {
                        $reasons[] = (new Li())->text($reason);
                    }

                    $infos[] = (new Para())
                        ->class(['module-requires', 'info'])
                        ->items([
                            (new Span(__('This module cannot be enabled, because of the following reasons :'))),
                            (new Ul())->items($reasons),
                        ]);
                }

                $data[] = (new Td())
                    ->class(['module-desc', 'maximal'])
                    ->items([
                        count($infos) ?
                        (new Details())
                            ->summary((new Summary(Html::escapeHTML(__($desc)))))
                            ->items($infos) :
                        (new Text(null, Html::escapeHTML(__($desc)))),
                    ]);
            }

            if (in_array('repository', $cols) && App::config()->allowRepositories()) {
                $tds++;
                $data[] = (new Td())
                    ->class(['module-repository', 'nowrap', 'count'])
                    ->text(empty($define->get('repository')) ? __('Official repository') : __('Third-party repository'));
            }

            if (in_array('distrib', $cols)) {
                $tds++;
                $data[] = (new Td())
                    ->class('module-distrib')
                    ->items([
                        $define->get('distributed') ?
                        (new Img('images/dotclear-leaf.svg'))->alt(__('Plugin from official distribution')) :
                        ($git ? (new Img('images/git-branch.svg'))->alt(__('Plugin in development')) : (new None())),
                    ]);
            }

            if ($actions !== [] && App::auth()->isSuperAdmin()) {
                $buttons = $this->getActions($define, $actions);

                $tds++;
                $data[] = (new Td())
                    ->class(['module-actions', 'nowrap'])
                    ->text(implode(' ', $buttons));
            }

            $rows[] = (new Tr(Html::escapeHTML($this->list_id) . '_m_' . Html::escapeHTML($id)))
                ->class(array_filter(['line', $git ? ' module-git' : '']))
                ->cols($data);
            if (in_array('desc', $cols)) {
                $desc = is_string($desc = $define->get('desc')) ? $desc : '';

                current($rows)->title(Html::escapeHTML(__($desc)));
            }

            # Other informations
            if (in_array('expander', $cols)) {
                $items = [];

                if (!empty($define->get('author')) || !empty($define->get('date')) || !empty($define->get('details')) || !empty($define->get('support'))) {
                    $lines = [];

                    if (!empty($define->get('author'))) {
                        $author = is_string($author = $define->get('author')) ? $author : '';

                        $lines[] = (new Li())
                            ->class('module-author')
                            ->text(__('Author:') . ' ' . Html::escapeHTML($author));
                    }

                    if (!empty($define->get('date'))) {
                        $date_format = App::blog()->settings()->get('system')->getStr('date_format', false) ?: __('%Y-%m-%d %H:%M');
                        $user_tz     = is_string($user_tz = App::auth()->getInfo('user_tz')) ? $user_tz : null;
                        $date        = is_string($date = $define->get('date')) ? $date : '';

                        $lines[] = (new Li())
                            ->class('module-date')
                            ->text(__('Release date:') . ' ' . Html::escapeHTML(Date::dt2str($date_format, $date, $user_tz)));
                    }

                    $links = [];
                    if (!empty($define->get('details'))) {
                        $details = is_string($details = $define->get('details')) ? $details : '';

                        $links[] = (new Link())
                            ->class('module-details')
                            ->href($details)
                            ->text(__('Details'));
                    }

                    if (!empty($define->get('support'))) {
                        $support = is_string($support = $define->get('support')) ? $support : '';

                        $links[] = (new Link())
                            ->class('module-support')
                            ->href($support)
                            ->text(__('Support'));
                    }

                    if ($define->updLocked()) {
                        $links[] = (new Span(__('update locked')))
                            ->class('module-locked');
                    }

                    if (!empty($links)) {
                        $lines[] = (new Li())
                            ->separator(' - ')
                            ->items($links);
                    }

                    $items[] = (new Div())->items([
                        (new Ul())->class('mod-more')->items($lines),
                    ]);
                }

                if (static::hasFileOrClass($id, $this->modules::MODULE_CLASS_CONFIG, $this->modules::MODULE_FILE_CONFIG)
                 || static::hasFileOrClass($id, $this->modules::MODULE_CLASS_MANAGE, $this->modules::MODULE_FILE_MANAGE)
                 || !empty($define->get('section'))
                 || !empty($define->get('tags'))
                 || !empty($define->get('settings'))   && $define->get('state') == ModuleDefine::STATE_ENABLED
                 || !empty($define->get('repository')) && App::config()->debugMode() && App::config()->allowRepositories()
                ) {
                    $lines = [];

                    $settings = static::getSettingsUrls($id, keys: true);

                    // Avoid duplicates URLs for settings
                    if (isset($settings['manage']) && isset($settings['self'])) {
                        $manage = [];
                        if (preg_match('/href="(.*?)"/', $settings['manage'], $manage)) {
                            $self = [];
                            if (preg_match('/href="(.*?)"/', $settings['self'], $self) && $manage[1] === $self[1]) {
                                // Manage and self are the same, no need to duplicate them, keep only self
                                unset($settings['manage']);
                            }
                        }
                    }

                    if ($settings !== [] && $define->get('state') == ModuleDefine::STATE_ENABLED) {
                        $lines[] = (new Li())
                            ->text(implode(' - ', array_values($settings)));
                    }

                    if (!empty($define->get('repository')) && App::config()->debugMode() && App::config()->allowRepositories()) {
                        $repository = is_string($repository = $define->get('repository')) ? $repository : '';

                        $lines[] = (new Li())
                            ->class('modules-repository')
                            ->items([
                                (new Link())->href($repository)->text(__('Third-party repository')),
                            ]);
                    }

                    if (!empty($define->get('section'))) {
                        $section = is_string($section = $define->get('section')) ? $section : '';

                        $lines[] = (new Li())
                            ->class('module-section')
                            ->text(__('Section:') . ' ' . Html::escapeHTML($section));
                    }

                    if (!empty($define->get('tags'))) {
                        $tags = is_string($tags = $define->get('tags')) ? $tags : '';

                        $lines[] = (new Li())
                            ->class('module-tags')
                            ->text(__('Tags:') . ' ' . Html::escapeHTML($tags));
                    }

                    $items[] = (new Div())->items([
                        (new Ul())->class('mod-more')->items($lines),
                    ]);
                }

                $rows[] = (new Tr())
                    ->class('module-more')
                    ->cols([
                        (new Td())
                            ->class('expand')
                            ->colspan($tds)
                            ->items($items),
                    ]);
            }

            $count++;
        }

        $bottom = [];
        if (!$count && $this->getSearch() === null) {
            $bottom[] = (new Note())
                ->class('message')
                ->text(__('No plugins matched your search.'));
        } elseif ((in_array('checkbox', $cols) || $count > 1) && $actions !== [] && App::auth()->isSuperAdmin()) {
            $buttons = $this->getGlobalActions($actions, in_array('checkbox', $cols));

            if ($buttons !== []) {
                if (in_array('checkbox', $cols)) {
                    $bottom[] = (new Para())->class('checkboxes-helpers');
                }

                $bottom[] = (new Div())->items([
                    (new Text(null, implode(' ', $buttons))),
                ]);
            }
        }

        $form = (new Form(['modules-form-actions']))
            ->method('post')
            ->action($this->getURL())
            ->fields([
                (new Div())
                    ->class('table-outer')
                    ->items([
                        (new Table(Html::escapeHTML($this->list_id)))
                            ->class(array_filter(['modules', in_array('expander', $cols) ? ' expandable' : '']))
                            ->caption((new Caption(Html::escapeHTML(__('Plugins list'))))->class('hidden'))
                            ->items($rows),
                    ]),
                ...$bottom,
                App::nonce()->formNonce(),
            ])
        ->render();

        echo $form;

        return $this;
    }

    /**
     * Same as displayModules(...) but without returning object instance
     *
     * @param    array<string>      $cols         List of columns (module field) to display
     * @param    array<string>      $actions      List of predefined actions to show on form
     * @param    bool               $nav_limit    Limit list to previously selected index
     */
    public function displayModulesFinal(array $cols = ['name', 'version', 'desc'], array $actions = [], bool $nav_limit = false): void
    {
        $this->displayModules($cols, $actions, $nav_limit);
    }

    /**
     * Get settings URLs if any
     *
     * @param   string  $id         Module ID
     * @param   boolean $check      Check permission
     * @param   boolean $self       Include self URL (→ plugin index.php URL)
     * @param   boolean $keys       Use string keys instead of numeric for links
     * @param   boolean $url_only   Get only URLs
     *
     * @return array<string>    Array of settings URLs
     */
    public static function getSettingsUrls(string $id, bool $check = false, bool $self = true, bool $keys = false, bool $url_only = false): array
    {
        $settings_urls = [];

        $config = static::hasFileOrClass($id, App::plugins()::MODULE_CLASS_CONFIG, App::plugins()::MODULE_FILE_CONFIG);
        $manage = static::hasFileOrClass($id, App::plugins()::MODULE_CLASS_MANAGE, App::plugins()::MODULE_FILE_MANAGE);

        $settings = App::plugins()->moduleInfo($id, 'settings');
        if ($self
            && is_array($settings)
            && isset($settings['self'])
            && $settings['self'] === false
        ) {
            $self = false;
        }

        if ($config || $manage || !empty($settings)) {
            $permissions = is_string($permissions = App::plugins()->moduleInfo($id, 'permissions')) ? $permissions : null;

            if ($config && (!$check || App::auth()->isSuperAdmin() || App::auth()->check($permissions, App::blog()->id()))) {
                $params = ['module' => $id, 'conf' => '1'];
                if (!App::plugins()->moduleInfo($id, 'standalone_config') && !$self) {
                    $params['redir'] = App::backend()->url()->get('admin.plugin.' . $id);
                }

                $index                 = $keys ? 'config' : count($settings_urls);
                $settings_urls[$index] = $url_only ?
                    App::backend()->url()->get('admin.plugins', $params) :
                    (new Link())
                        ->href(App::backend()->url()->get('admin.plugins', $params))
                        ->class('module-config')
                        ->text(__('Configure plugin'))
                    ->render();
            }

            if (is_array($settings)) {
                foreach ($settings as $sk => $sv) {
                    $sv = is_string($sv) ? $sv : '';

                    switch ($sk) {
                        case 'blog':
                            if (!$check || App::auth()->isSuperAdmin() || App::auth()->check(App::auth()->makePermissions([
                                App::auth()::PERMISSION_ADMIN,
                            ]), App::blog()->id())) {
                                $index                 = $keys ? 'blog' : count($settings_urls);
                                $settings_urls[$index] = $url_only ?
                                    App::backend()->url()->get('admin.blog.pref') . $sv :
                                    (new Link())
                                        ->href(App::backend()->url()->get('admin.blog.pref') . $sv)
                                        ->class('module-config')
                                        ->text(__('Plugin settings (in blog parameters)'))
                                    ->render();
                            }

                            break;
                        case 'pref':
                            if (!$check || App::auth()->isSuperAdmin() || App::auth()->check(App::auth()->makePermissions([
                                App::auth()::PERMISSION_USAGE,
                                App::auth()::PERMISSION_CONTENT_ADMIN,
                            ]), App::blog()->id())) {
                                $index                 = $keys ? 'pref' : count($settings_urls);
                                $settings_urls[$index] = $url_only ?
                                    App::backend()->url()->get('admin.user.preferences') . $sv :
                                    (new Link())
                                        ->href(App::backend()->url()->get('admin.user.preferences') . $sv)
                                        ->class('module-config')
                                        ->text(__('Plugin settings (in user preferences)'))
                                    ->render();
                            }

                            break;
                        case 'self':
                            if ($self) {
                                if (!$check || App::auth()->isSuperAdmin() || App::auth()->check($permissions, App::blog()->id())) {
                                    $index                 = $keys ? 'self' : count($settings_urls);
                                    $settings_urls[$index] = $url_only ?
                                        App::backend()->url()->get('admin.plugin.' . $id) . $sv :
                                        (new Link())
                                            ->href(App::backend()->url()->get('admin.plugin.' . $id) . $sv)
                                            ->class('module-config')
                                            ->text(__('Plugin settings'))
                                        ->render();
                                }

                                // No need to use default index.php
                                $index = false;
                            }

                            break;
                        case 'other':
                            if (!$check || App::auth()->isSuperAdmin() || App::auth()->check($permissions, App::blog()->id())) {
                                $index                 = $keys ? 'other' : count($settings_urls);
                                $settings_urls[$index] = $url_only ?
                                    $sv :
                                    (new Link())
                                        ->href($sv)
                                        ->class('module-config')
                                        ->text(__('Plugin settings'))
                                    ->render();
                            }

                            break;
                    }
                }
            }

            if ($manage && $self && (!$check || App::auth()->isSuperAdmin() || App::auth()->check($permissions, App::blog()->id()))) {
                $index                 = $keys ? 'manage' : count($settings_urls);
                $settings_urls[$index] = $url_only ?
                    App::backend()->url()->get('admin.plugin.' . $id) :
                    (new Link())
                        ->href(App::backend()->url()->get('admin.plugin.' . $id))
                        ->class('module-config')
                        ->text(__('Plugin main page'))
                    ->render();
            }
        }

        return $settings_urls;
    }

    /**
     * Get action buttons to add to modules list.
     *
     * @param    ModuleDefine     $define     Module info
     * @param    array<string>    $actions    Actions keys
     *
     * @return   array<string>    Array of actions buttons
     */
    protected function getActions(ModuleDefine $define, array $actions): array
    {
        $submits = [];
        $id      = $define->getId();

        // mark module state
        if ($define->get('state') != ModuleDefine::STATE_ENABLED) {
            $submits[] = (new Hidden(['disabled[' . Html::escapeHTML($id) . ']'], '1'))
            ->render();
        }

        $root = is_string($root = $define->get('root')) ? $root : '';

        # Use loop to keep requested order
        foreach ($actions as $action) {
            switch ($action) {
                # Activate
                case 'activate':
                    // Check module root
                    if ($root === '') {
                        break;
                    }

                    // Do not allow activation of symlinked modules
                    if (is_link($root)) {
                        break;
                    }

                    // do not allow activation of duplicate modules already activated
                    $multi = !self::$allow_multi_install && $this->modules->getDefines(['id' => $id, 'state' => ModuleDefine::STATE_ENABLED]) !== [];
                    if (App::auth()->isSuperAdmin() && $define->get('root_writable') && $define->getMissing() === [] && !$multi) {
                        $submits[] = (new Submit(['activate[' . Html::escapeHTML($id) . ']'], __('Activate')))
                        ->render();
                    }

                    break;

                    # Dectivate
                case 'deactivate':
                    // Check module root
                    if ($root === '') {
                        break;
                    }

                    // Do not allow deactivation of symlinked modules
                    if (is_link($root)) {
                        break;
                    }

                    if (App::auth()->isSuperAdmin() && $define->get('root_writable') && $define->getUsing() === []) {
                        $submits[] = (new Submit(['deactivate[' . Html::escapeHTML($id) . ']'], __('Deactivate')))
                            ->class('reset')
                        ->render();
                    }

                    break;

                    # Delete
                case 'delete':
                    // Check module root
                    if ($root === '') {
                        break;
                    }

                    if (App::auth()->isSuperAdmin() && !$define->distributed && $this->isDeletablePath($root) && $define->getUsing() === []) {
                        $dev       = !preg_match('!^' . $this->path_pattern . '!', $root) && App::config()->devMode() ? ' debug' : '';
                        $submits[] = (new Submit(['delete[' . Html::escapeHTML($id) . ']'], __('Delete')))
                            ->class(array_filter(['delete', $dev]))
                        ->render();
                    }

                    break;

                    # Clone
                case 'clone':
                    // Check module root
                    if ($root === '') {
                        break;
                    }

                    // Do not allow cloning of symlinked modules
                    if (is_link($root)) {
                        break;
                    }

                    if (App::auth()->isSuperAdmin() && $this->path_writable) {
                        $submits[] = (new Submit(['clone[' . Html::escapeHTML($id) . ']'], __('Clone')))
                            ->class('clone')
                        ->render();
                    }

                    break;

                    # Install (from store)
                case 'install':
                    if (App::auth()->isSuperAdmin() && $this->path_writable) {
                        $submits[] = (new Submit(['install[' . Html::escapeHTML($id) . ']'], __('Install')))
                        ->render();
                    }

                    break;

                    # Update (from store)
                case 'update':
                    if (App::auth()->isSuperAdmin() && $this->path_writable && !$define->updLocked()) {
                        $submits[] = (new Submit(['update[' . Html::escapeHTML($id) . ']'], __('Update')))
                        ->render();
                    }

                    break;

                    # Behavior
                case 'behavior':

                    # --BEHAVIOR-- adminModulesListGetActions -- ModulesList, ModuleDefine
                    $tmp = App::behavior()->callBehavior('adminModulesListGetActionsV2', $this, $define);

                    if ($tmp !== '') {
                        $submits[] = $tmp;
                    }

                    break;
            }
        }

        return $submits;
    }

    /**
     * Get global action buttons to add to modules list.
     *
     * @param   array<string>   $actions            Actions keys
     * @param   bool            $with_selection     Limit action to selected modules
     *
     * @return  array<string>   Array of actions buttons
     */
    protected function getGlobalActions(array $actions, bool $with_selection = false): array
    {
        $submits = [];

        # Use loop to keep requested order
        foreach ($actions as $action) {
            switch ($action) {
                # Deactivate
                case 'activate':
                    if (App::auth()->isSuperAdmin() && $this->path_writable) {
                        $submits[] = (new Submit(['activate'], $with_selection ?
                            __('Activate selected plugins') :
                            __('Activate all plugins from this list')))
                        ->render();
                    }

                    break;

                    # Activate
                case 'deactivate':
                    if (App::auth()->isSuperAdmin() && $this->path_writable) {
                        $submits[] = (new Submit(['deactivate'], $with_selection ?
                            __('Deactivate selected plugins') :
                            __('Deactivate all plugins from this list')))
                        ->render();
                    }

                    break;

                    # Update (from store)
                case 'update':
                    if (App::auth()->isSuperAdmin() && $this->path_writable) {
                        $submits[] = (new Submit(['update'], $with_selection ?
                            __('Update selected plugins') :
                            __('Update all plugins from this list')))
                        ->render();
                    }

                    break;

                    # Behavior
                case 'behavior':

                    # --BEHAVIOR-- adminModulesListGetGlobalActions -- ModulesList, bool
                    $tmp = App::behavior()->callBehavior('adminModulesListGetGlobalActions', $this, $with_selection);

                    if ($tmp !== '') {
                        $submits[] = $tmp;
                    }

                    break;
            }
        }

        return $submits;
    }

    /**
     * Execute POST action.
     *
     * Set a notice on success through App::backend()->notices()->addSuccessNotice
     *
     * @throws    Exception    Module not find or command failed
     */
    public function doActions(): void
    {
        if ($_POST === [] || !empty($_REQUEST['conf'])
                          || !$this->isWritablePath()) {
            return;
        }

        $modules = !empty($_POST['modules']) && is_array($_POST['modules']) ? array_values($_POST['modules']) : [];

        if (App::auth()->isSuperAdmin() && !empty($_POST['delete'])) {
            if (is_array($_POST['delete'])) {
                $modules = array_keys($_POST['delete']);
            }

            $failed = false;
            $count  = 0;
            foreach ($modules as $id) {
                if (is_string($id)) {
                    $disabled = isset($_POST['disabled']) && is_array($_POST['disabled']) && !empty($_POST['disabled'][$id]);
                    $define   = $this->modules->getDefine($id, ['state' => ($disabled ? '!' : '') . ModuleDefine::STATE_ENABLED]);
                    // module is not defined
                    if (!$define->isDefined()) {
                        throw new Exception(__('No such plugin.'));
                    }

                    $root = is_string($root = $define->get('root')) ? $root : '';
                    if ($root === '') {
                        continue;
                    }

                    $symlink = is_link($root);
                    if (!$symlink && !$this->isDeletablePath($root)) {
                        $failed = true;

                        continue;
                    }

                    # --BEHAVIOR-- moduleBeforeDelete -- ModuleDefine
                    App::behavior()->callBehavior('pluginBeforeDeleteV2', $define);

                    $this->modules->deleteModule($define->getId(), $disabled);

                    # --BEHAVIOR-- moduleAfterDelete -- ModuleDefine
                    App::behavior()->callBehavior('pluginAfterDeleteV2', $define);

                    $count++;
                }
            }

            if (!$count && $failed) {
                throw new Exception(__("You don't have permissions to delete this plugin."));
            }

            if ($failed) {
                App::backend()->notices()->addWarningNotice(__('Some plugins have not been delete.'));
            } else {
                App::backend()->notices()->addSuccessNotice(
                    __('Plugin has been successfully deleted.', 'Plugins have been successuflly deleted.', $count)
                );
            }

            Http::redirect($this->getURL());
        } elseif (App::auth()->isSuperAdmin() && !empty($_POST['install'])) {
            if (is_array($_POST['install'])) {
                $modules = array_keys($_POST['install']);
            }

            $count = 0;
            foreach ($this->store->getDefines() as $define) {
                if (!in_array($define->getId(), $modules)) {
                    continue;
                }

                $file = is_string($file = $define->get('file')) ? $file : '';
                if ($file === '') {
                    continue;
                }

                $dest = $this->getPath() . DIRECTORY_SEPARATOR . basename($file);

                # --BEHAVIOR-- moduleBeforeAdd -- ModuleDefine
                App::behavior()->callBehavior('pluginBeforeAddV2', $define);

                $this->store->process($file, $dest);

                # --BEHAVIOR-- moduleAfterAdd -- ModuleDefine
                App::behavior()->callBehavior('pluginAfterAddV2', $define);

                $count++;
            }

            if ($count === 0) {
                throw new Exception(__('No such plugin.'));
            }

            App::backend()->notices()->addSuccessNotice(
                __('Plugin has been successfully installed.', 'Plugins have been successfully installed.', $count)
            );
            Http::redirect($this->getURL());
        } elseif (App::auth()->isSuperAdmin() && !empty($_POST['activate'])) {
            if (is_array($_POST['activate'])) {
                $modules = array_keys($_POST['activate']);
            }

            $count = 0;
            foreach ($modules as $id) {
                if (is_string($id)) {
                    $define = $this->modules->getDefine($id, ['state' => '!' . ModuleDefine::STATE_ENABLED]);
                    if (!$define->isDefined()) {
                        continue;
                    }

                    # --BEHAVIOR-- moduleBeforeActivate -- string
                    App::behavior()->callBehavior('pluginBeforeActivate', $define->getId());

                    $this->modules->activateModule($define->getId());

                    # --BEHAVIOR-- moduleAfterActivate -- string
                    App::behavior()->callBehavior('pluginAfterActivate', $define->getId());

                    $count++;
                }
            }

            if ($count === 0) {
                throw new Exception(__('No such plugin.'));
            }

            App::backend()->notices()->addSuccessNotice(
                __('Plugin has been successfully activated.', 'Plugins have been successuflly activated.', $count)
            );
            Http::redirect($this->getURL());
        } elseif (App::auth()->isSuperAdmin() && !empty($_POST['deactivate'])) {
            if (is_array($_POST['deactivate'])) {
                $modules = array_keys($_POST['deactivate']);
            }

            $failed = false;
            $count  = 0;
            foreach ($modules as $id) {
                if (is_string($id)) {
                    $define = $this->modules->getDefine($id, ['state' => '!' . ModuleDefine::STATE_HARD_DISABLED]);
                    if (!$define->isDefined()) {
                        continue;
                    }

                    if (!$define->get('root_writable')) {
                        $failed = true;

                        continue;
                    }

                    # --BEHAVIOR-- moduleBeforeDeactivate -- ModuleDefine
                    App::behavior()->callBehavior('pluginBeforeDeactivateV2', $define);

                    $this->modules->deactivateModule($define->getId());

                    # --BEHAVIOR-- moduleAfterDeactivate -- ModuleDefine
                    App::behavior()->callBehavior('pluginAfterDeactivateV2', $define);

                    $count++;
                }
            }

            if ($count === 0) {
                throw new Exception(__('No such plugin.'));
            }

            if ($failed) {
                App::backend()->notices()->addWarningNotice(__('Some plugins have not been deactivated.'));
            } else {
                App::backend()->notices()->addSuccessNotice(
                    __('Plugin has been successfully deactivated.', 'Plugins have been successuflly deactivated.', $count)
                );
            }

            Http::redirect($this->getURL());
        } elseif (App::auth()->isSuperAdmin() && !empty($_POST['update'])) {
            if (is_array($_POST['update'])) {
                $modules = array_keys($_POST['update']);
            }

            $locked  = [];
            $count   = 0;
            $defines = $this->store->getDefines(true);
            foreach ($defines as $define) {
                if (!in_array($define->getId(), $modules)) {
                    continue;
                }

                if ($define->updLocked()) {
                    $locked[] = $define->get('name');

                    continue;
                }

                $root = is_string($root = $define->get('root')) ? $root : '';
                if ($root === '') {
                    continue;
                }

                $file = is_string($file = $define->get('file')) ? $file : '';
                if ($file === '') {
                    continue;
                }

                if (!self::$allow_multi_install) {
                    $dest = implode(DIRECTORY_SEPARATOR, [Path::dirWithSym($root), '..', basename($file)]);
                } else {
                    $dest = $this->getPath() . DIRECTORY_SEPARATOR . basename($file);
                    if ($root !== $dest) {
                        @file_put_contents($root . DIRECTORY_SEPARATOR . $this->modules::MODULE_FILE_DISABLED, '');
                    }
                }

                # --BEHAVIOR-- moduleBeforeUpdate -- ModuleDefine
                App::behavior()->callBehavior('pluginBeforeUpdateV2', $define);

                $this->store->process($file, $dest);

                # --BEHAVIOR-- moduleAfterUpdate -- ModuleDefine
                App::behavior()->callBehavior('pluginAfterUpdateV2', $define);

                $count++;
            }

            $tab = $count === count($defines) ? 'plugins' : 'update';

            if ($count !== 0) {
                App::backend()->notices()->addSuccessNotice(
                    __('Plugin has been successfully updated.', 'Plugins have been successfully updated.', $count)
                );
            } elseif ($locked !== []) {
                $locked = array_filter($locked, is_string(...));
                App::backend()->notices()->addWarningNotice(
                    sprintf(__('Following plugins updates are locked: %s.'), implode(', ', $locked))
                );
            } else {
                throw new Exception(__('No such plugin.'));
            }

            Http::redirect($this->getURL('', true, $tab));
        }

        # Manual actions
        elseif (!empty($_POST['upload_pkg']) && !empty($_FILES['pkg_file'])
            || !empty($_POST['fetch_pkg'])   && !empty($_POST['pkg_url'])) {
            if (empty($_POST['your_pwd'])
                || !is_string($_POST['your_pwd'])
                // @phpstan-ignore argument.type (false positive)
                || !App::auth()->checkPassword($_POST['your_pwd'])
            ) {
                throw new Exception(__('Password verification failed.'));
            }

            $dest = '';
            if (!empty($_POST['upload_pkg'])) {
                /**
                 * @var array{name: string, type: string, size: int, tmp_name: string, error?: int, full_path: string}  $pkg_file
                 */
                $pkg_file = $_FILES['pkg_file'];
                Files::uploadStatus($pkg_file);

                $dest = $this->getPath() . DIRECTORY_SEPARATOR . $pkg_file['name'];
                if (!move_uploaded_file($pkg_file['tmp_name'], $dest)) {
                    throw new Exception(__('Unable to move uploaded file.'));
                }
            } else {
                $url = is_string($url = $_POST['pkg_url']) ? $url : '';
                if ($url !== '') {
                    $url  = urldecode($url);
                    $dest = $this->getPath() . DIRECTORY_SEPARATOR . basename($url);
                    $this->store->download($url, $dest);
                }
            }

            if ($dest === '') {
                throw new Exception(__('Upload or download error.'));
            }

            # --BEHAVIOR-- moduleBeforeAdd --
            App::behavior()->callBehavior('pluginBeforeAdd', null);

            $ret_code = $this->store->install($dest);

            # --BEHAVIOR-- moduleAfterAdd --
            App::behavior()->callBehavior('pluginAfterAdd', null);

            App::backend()->notices()->addSuccessNotice(
                $ret_code === $this->modules::PACKAGE_UPDATED ?
                __('The plugin has been successfully updated.') :
                __('The plugin has been successfully installed.')
            );
            Http::redirect($this->getURL('', true, 'plugins'));
        } else {
            # --BEHAVIOR-- adminModulesListDoActions -- ModulesList, array<int,string>, string
            App::behavior()->callBehavior('adminModulesListDoActions', $this, $modules, 'plugin');
        }
    }

    /**
     * Display tab for manual installation.
     */
    public function displayManualForm(): ?self
    {
        if (!App::auth()->isSuperAdmin() || !$this->isWritablePath()) {
            return null;
        }

        // 'Upload module' form
        echo (new Form())
            ->method('post')
            ->action($this->getURL())
            ->enctype('multipart/form-data')
            ->id('uploadpkg')
            ->items([
                (new Fieldset())
                    ->legend(new Legend(__('Upload a zip file')))
                    ->items([
                        (new Note())
                            ->class('form-note')
                            ->text(sprintf(__('Fields preceded by %s are mandatory.'), (new Span('*'))->class('required')->render())),
                        (new Para())
                            ->class('field')
                            ->items([
                                (new File('pkg_file'))
                                    ->required(true)
                                    ->label(
                                        (new Label((new Span('*'))->render() . __('Zip file path:'), Label::OL_TF))
                                            ->class('required')
                                    ),
                            ]),
                        (new Para())
                            ->class('field')
                            ->items([
                                (new Password(['your_pwd', 'your_pwd1']))
                                    ->size(20)
                                    ->maxlength(255)
                                    ->required(true)
                                    ->placeholder(__('Password'))
                                    ->autocomplete('current-password')
                                    ->label(
                                        (new Label((new Span('*'))->render() . __('Your password:'), Label::OL_TF))
                                            ->class('required')
                                    ),
                            ]),
                        (new Para())
                            ->class('form-buttons')
                            ->items([
                                App::nonce()->formNonce(),
                                (new Submit(['upload_pkg'], __('Upload'))),
                            ]),
                    ]),
            ])
        ->render();

        // 'Fetch module' form
        echo (new Form())
            ->method('post')
            ->action($this->getURL())
            ->enctype('multipart/form-data')
            ->id('fetchpkg')
            ->items([
                (new Fieldset())
                    ->legend(new Legend(__('Download a zip file')))
                    ->items([
                        (new Note())
                            ->class('form-note')
                            ->text(sprintf(__('Fields preceded by %s are mandatory.'), (new Span('*'))->class('required')->render())),
                        (new Para())
                            ->class('field')
                            ->items([
                                (new Url('pkg_url'))
                                    ->size(40)
                                    ->maxlength(255)
                                    ->required(true)
                                    ->placeholder(__('URL'))
                                    ->label(
                                        (new Label((new Span('*'))->render() . __('Zip file URL:'), Label::OL_TF))
                                            ->class('required')
                                    ),
                            ]),
                        (new Para())
                            ->class('field')
                            ->items([
                                (new Password(['your_pwd', 'your_pwd2']))
                                    ->size(20)
                                    ->maxlength(255)
                                    ->required(true)
                                    ->placeholder(__('Password'))
                                    ->autocomplete('current-password')
                                    ->label(
                                        (new Label((new Span('*'))->render() . __('Your password:'), Label::OL_TF))
                                            ->class('required')
                                    ),
                            ]),
                        (new Para())
                            ->class('form-buttons')
                            ->items([
                                App::nonce()->formNonce(),
                                (new Submit(['fetch_pkg'], __('Download'))),
                            ]),
                    ]),
            ])
        ->render();

        return $this;
    }

    /**
     * Same as displayManualForm but without returning object instance
     */
    public function displayManualFormFinal(): void
    {
        $this->displayManualForm();
    }

    ///@}

    /// @name Module configuration methods
    ///@{

    /**
     * Prepare module configuration.
     *
     * We need to get configuration content in three steps
     * and out of this class to keep backward compatibility.
     *
     * if ($xxx->setConfiguration()) {
     *    include $xxx->includeConfiguration();
     * }
     * $xxx->getConfiguration();
     * ... [put here page headers and other stuff]
     * $xxx->displayConfiguration();
     *
     * @param    string|null    $id        Module to work on or it gather through REQUEST
     *
     * @return   bool  True if config set
     */
    public function setConfiguration(?string $id = null): bool
    {
        if (empty($_REQUEST['conf']) || empty($_REQUEST['module']) && !$id) {
            return false;
        }

        if (!empty($_REQUEST['module']) && ($id === null || $id === '')) {
            $id = $_REQUEST['module'];
        }

        $id = is_string($id) ? $id : '';
        if ($id === '') {
            return false;
        }

        $define = $this->modules->getDefine($id, ['state' => ModuleDefine::STATE_ENABLED]);
        if (!$define->isDefined()) {
            App::error()->add(__('Unknown plugin ID.'));

            return false;
        }

        self::fillSanitizeModule($define);

        $namespace = is_string($namespace = $define->get('namespace')) ? $namespace : '';
        $class     = $namespace . Autoloader::NS_SEP . $this->modules::MODULE_CLASS_CONFIG;
        $class     = App::task()->isProcessClass($class) ? $class : '';

        $root = is_string($root = $define->get('root')) ? $root : '';
        if ($root === '') {
            App::error()->add(__('This plugin has no configuration file.'));

            return false;
        }

        $file = (string) Path::real($root . DIRECTORY_SEPARATOR . $this->modules::MODULE_FILE_CONFIG);

        if ($class === '' && $file === '') {
            App::error()->add(__('This plugin has no configuration file.'));

            return false;
        }

        $permissions = is_string($permissions = App::plugins()->moduleInfo($id, 'permissions')) ? $permissions : null;
        if (!App::auth()->isSuperAdmin()
            && !App::auth()->check($permissions, App::blog()->id())
        ) {
            App::error()->add(__('Insufficient permissions.'));

            return false;
        }

        $this->config_define  = $define;
        $this->config_class   = $class;
        $this->config_file    = $file;
        $this->config_content = '';

        App::task()->addContext('MODULE');

        return true;
    }

    /**
     * Get full path of module configuration file.
     *
     * @note Required previously set file info
     */
    public function includeConfiguration(): ?string
    {
        if ($this->config_class === '' && $this->config_file === '') {
            return null;
        }

        $this->setRedir($this->getURL('', true, 'plugins'));

        ob_start();

        if ($this->config_class !== '') {
            if ($this->config_class::init() && $this->config_class::process()) {
                $this->config_class::render();
            }

            return null;
        }

        return $this->config_file;
    }

    /**
     * Gather module configuration file content.
     *
     * @note Required previously file inclusion
     *
     * @return bool     True if content has been captured
     */
    public function getConfiguration(): bool
    {
        if ($this->config_class !== '' || $this->config_file !== '') {
            $content              = ob_get_contents();
            $this->config_content = $content === false ? '' : $content;
        }

        ob_end_clean();

        return $this->config_content !== '';
    }

    /**
     * Display module configuration form.
     *
     * @note Required previously gathered content
     *
     * @return    ModulesList self instance
     */
    public function displayConfiguration(): ModulesList
    {
        if ($this->config_define instanceof ModuleDefine && ($this->config_class !== '' || $this->config_file !== '')) {
            $items = [
                (new Para())
                    ->items([
                        (new Link())
                            ->class('back')
                            ->href($this->getRedir())
                            ->text(__('Back')),
                    ]),
                (new Text(null, $this->config_content)),
            ];

            if (!$this->config_define->get('standalone_config') && !$this->config_define->get('information_config')) {
                // Module config is not standalone or only informative so it must be encapsulated in a form
                echo (new Form())
                    ->method('post')
                    ->action($this->getURL('conf=1'))
                    ->enctype('multipart/form-data')
                    ->id('module_config')
                    ->items([
                        ...$items,
                        (new Para())
                            ->class('clear')
                            ->items([
                                (new Submit(['save'], __('Save'))),
                                (new Hidden('module', $this->config_define->getId())),
                                (new Hidden('redir', $this->getRedir())),
                                App::nonce()->formNonce(),
                            ]),
                    ])
                ->render();
                // Add form monitoring
                echo App::backend()->page()->jsConfirmClose('module_config');
            } else {
                echo (new Set())
                    ->items($items)
                ->render();
            }
        }

        return $this;
    }

    ///@}

    /**
     * Helper to sanitize a string.
     *
     * Used for search or id.
     *
     * @param    string    $str        String to sanitize
     *
     * @return   string     Sanitized string
     */
    public static function sanitizeString(string $str): string
    {
        return (string) preg_replace('/[^A-Za-z0-9\@\#+_-]/', '', strtolower($str));
    }

    /**
     * Helper to check if a module's ns class or file exists.
     *
     * @param   string  $id     The module identifier
     * @param   string  $class  The module class name
     * @param   string  $file   The module file name
     */
    protected static function hasFileOrClass(string $id, string $class, string $file): bool
    {
        // by class name
        $namespace = is_string($namespace = App::plugins()->moduleInfo($id, 'namespace')) ? $namespace : '';
        $class     = $namespace . Autoloader::NS_SEP . $class;
        if ($namespace !== '' && class_exists($class)) {
            $has = (bool) $class::init();
            // by file name
        } else {
            $root = is_string($root = App::plugins()->moduleInfo($id, 'root')) ? $root : '';
            $has  = $root !== '' && file_exists((string) Path::real($root . DIRECTORY_SEPARATOR . $file));
        }

        return $has;
    }
}
