<?php

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

namespace Dotclear\Core\Upgrade;

use Dotclear\App;
use Dotclear\Core\Backend\ModulesList;
use Dotclear\Helper\File\Files;
use Dotclear\Helper\File\Path;
use Dotclear\Helper\Html\Form\Hidden;
use Dotclear\Helper\Html\Form\Submit;
use Dotclear\Helper\Html\Html;
use Dotclear\Helper\Network\Http;
use Dotclear\Interface\Module\ModulesInterface;
use Dotclear\Module\ModuleDefine;
use Dotclear\Module\Store;
use Exception;

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

    /**
     * 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(
        ModulesInterface $modules,
        string $modules_root,
        ?string $xml_url,
        ?bool $force = false,
        bool $use_cache_only = false,
    ) {
        if ($force === true) {
            $use_cache_only = false;
        }

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

        $this->setURL(App::upgrade()->url()->get('upgrade.plugins'));

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

    /**
     * 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
    {
        return [];
    }

    /**
     * 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();
        }

        # Use loop to keep requested order
        foreach ($actions as $action) {
            switch ($action) {
                # Deactivate
                case 'activate':
                    // do not allow activation of duplciate modules already activated
                    $multi = !self::$allow_multi_install && $this->modules->getDefines(['id' => $id, 'state' => ModuleDefine::STATE_ENABLED]) !== [];
                    if ($define->get('root_writable') && $define->getMissing() === [] && !$multi) {
                        $submits[] = (new Submit(['activate[' . Html::escapeHTML($id) . ']'], __('Activate')))
                        ->render();
                    }

                    break;

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

                    break;

                    # Delete
                case 'delete':
                    $root = is_string($root = $define->get('root')) ? $root : '';
                    if (!$define->distributed && $root !== '' && $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;

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

                    break;

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

                    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 ($this->path_writable) {
                        $submits[] = (new Submit(['activate'], $with_selection ?
                            __('Activate selected plugins') :
                            __('Activate all plugins from this list')))
                        ->render();
                    }

                    break;

                    # Activate
                case 'deactivate':
                    if ($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 ($this->path_writable) {
                        $submits[] = (new Submit(['update'], $with_selection ?
                            __('Update selected plugins') :
                            __('Update all plugins from this list')))
                        ->render();
                    }

                    break;
            }
        }

        return $submits;
    }

    /**
     * Execute POST action.
     *
     * Set a notice on success through App::upgrade()->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 (!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 === '' || !$this->isDeletablePath($root)) {
                        $failed = true;

                        continue;
                    }

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

                    $count++;
                }
            }

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

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

            Http::redirect($this->getURL());
        } elseif (!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 !== '') {
                    $dest = $this->getPath() . DIRECTORY_SEPARATOR . basename($file);

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

                    $count++;
                }
            }

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

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

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

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

                    $count++;
                }
            }

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

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

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

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

                        continue;
                    }

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

                    $count++;
                }
            }

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

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

            Http::redirect($this->getURL());
        } elseif (!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[] = is_string($name = $define->get('name')) ? $name : $define->getId();

                    continue;
                }

                $root = is_string($root = $define->get('root')) ? $root : '';
                $file = is_string($file = $define->get('file')) ? $file : '';
                if ($root !== '' && $file !== '') {
                    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, '');
                        }
                    }

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

                    $count++;
                }
            }

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

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

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

        # Manual actions
        elseif (!empty($_POST['upload_pkg']) && !empty($_FILES['pkg_file'])
            || !empty($_POST['fetch_pkg'])   && !empty($_POST['pkg_url'])) {
            $your_pwd = isset($_POST['your_pwd']) && is_string($your_pwd = $_POST['your_pwd']) ? $your_pwd : '';
            if ($your_pwd === '' || !App::auth()->checkPassword($your_pwd)) {
                throw new Exception(__('Password verification failed'));
            }

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

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

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

            App::upgrade()->notices()->addSuccessNotice(
                $ret_code === $this->modules::PACKAGE_UPDATED ?
                __('The plugin has been successfully updated.') :
                __('The plugin has been successfully installed.')
            );
            Http::redirect($this->getURL('do_install', true, 'plugins', parametric: false));
        }
    }

    /**
     * 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
     *
     * @return  bool    True if one exists
     */
    protected static function hasFileOrClass(string $id, string $class, string $file): bool
    {
        return false;
    }
}
