<?php

// SPDX-FileCopyrightText: 2018 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later

namespace Icinga\Web;

use Icinga\Forms\ConfigForm;
use Icinga\Module\Setup\Forms\ModulePage;
use LogicException;
use InvalidArgumentException;
use Icinga\Web\Session\SessionNamespace;
use Icinga\Web\Form\Decorator\ElementDoubler;

/**
 * Container and controller for form based wizards
 */
class Wizard
{
    /**
     * An integer describing the wizard's forward direction
     */
    const FORWARD = 0;

    /**
     * An integer describing the wizard's backward direction
     */
    const BACKWARD = 1;

    /**
     * An integer describing that the wizard does not change its position
     */
    const NO_CHANGE = 2;

    /**
     * The name of the button to advance the wizard's position
     */
    const BTN_NEXT = 'btn_next';

    /**
     * The name of the button to rewind the wizard's position
     */
    const BTN_PREV = 'btn_prev';

    /**
     * The name and id of the element for showing the user an activity indicator when advancing the wizard
     */
    const PROGRESS_ELEMENT = 'wizard_progress';

    /**
     * This wizard's parent
     *
     * @var Wizard
     */
    protected $parent;

    /**
     * The name of the wizard's current page
     *
     * @var string
     */
    protected $currentPage;

    /**
     * The pages being part of this wizard
     *
     * @var array
     */
    protected $pages = array();

    /**
     * Initialize a new wizard
     */
    public function __construct()
    {
        $this->init();
    }

    /**
     * Run additional initialization routines
     *
     * Should be implemented by subclasses to add pages to the wizard.
     */
    protected function init()
    {
    }

    /**
     * Return this wizard's parent or null in case it has none
     *
     * @return  Wizard|null
     */
    public function getParent()
    {
        return $this->parent;
    }

    /**
     * Set this wizard's parent
     *
     * @param   Wizard  $wizard     The parent wizard
     *
     * @return  $this
     */
    public function setParent(Wizard $wizard)
    {
        $this->parent = $wizard;
        return $this;
    }

    /**
     * Return the pages being part of this wizard
     *
     * In case this is a nested wizard a flattened array of all contained pages is returned.
     *
     * @return  array
     */
    public function getPages()
    {
        $pages = array();
        foreach ($this->pages as $page) {
            if ($page instanceof self) {
                $pages = array_merge($pages, $page->getPages());
            } else {
                $pages[] = $page;
            }
        }

        return $pages;
    }

    /**
     * Return the page with the given name
     *
     * Note that it's also possible to retrieve a nested wizard's page by using this method.
     *
     * @param   string                 $name    The name of the page to return
     *
     * @return  ModulePage|Form|null            The page or null in case there is no page with the given name
     */
    public function getPage($name)
    {
        foreach ($this->getPages() as $page) {
            if ($name === $page->getName()) {
                return $page;
            }
        }
    }

    /**
     * Add a new page or wizard to this wizard
     *
     * @param   Form|Wizard     $page   The page or wizard to add to the wizard
     *
     * @return  $this
     */
    public function addPage($page)
    {
        if (! $page instanceof Form && ! $page instanceof self) {
            throw new InvalidArgumentException(
                'The $page argument must be an instance of Icinga\Web\Form '
                . 'or Icinga\Web\Wizard but is of type: ' . get_class($page)
            );
        } elseif ($page instanceof self) {
            $page->setParent($this);
        }

        $this->pages[] = $page;
        return $this;
    }

    /**
     * Add multiple pages or wizards to this wizard
     *
     * @param   array   $pages      The pages or wizards to add to the wizard
     *
     * @return  $this
     */
    public function addPages(array $pages)
    {
        foreach ($pages as $page) {
            $this->addPage($page);
        }

        return $this;
    }

    /**
     * Assert that this wizard has any pages
     *
     * @throws  LogicException      In case this wizard has no pages
     */
    protected function assertHasPages()
    {
        $pages = $this->getPages();
        if (count($pages) < 2) {
            throw new LogicException("Although Chuck Norris can advance a wizard with less than two pages, you can't.");
        }
    }

    /**
     * Return the current page of this wizard
     *
     * @return  Form
     *
     * @throws  LogicException      In case the name of the current page currently being set is invalid
     */
    public function getCurrentPage()
    {
        if ($this->parent) {
            return $this->parent->getCurrentPage();
        }

        if ($this->currentPage === null) {
            $this->assertHasPages();
            $pages = $this->getPages();
            $this->currentPage = $this->getSession()->get('current_page', $pages[0]->getName());
        }

        if (($page = $this->getPage($this->currentPage)) === null) {
            throw new LogicException(sprintf('No page found with name "%s"', $this->currentPage));
        }

        return $page;
    }

    /**
     * Set the current page of this wizard
     *
     * @param   Form    $page   The page to set as current page
     *
     * @return  $this
     */
    public function setCurrentPage(Form $page)
    {
        $this->currentPage = $page->getName();
        $this->getSession()->set('current_page', $this->currentPage);
        return $this;
    }

    /**
     * Setup the given page that is either going to be displayed or validated
     *
     * Implement this method in a subclass to populate default values and/or other data required to process the form.
     *
     * @param   Form        $page       The page to setup
     * @param   Request     $request    The current request
     */
    public function setupPage(Form $page, Request $request)
    {
    }

    /**
     * Process the given request using this wizard
     *
     * Validate the request data using the current page, update the wizard's
     * position and redirect to the page's redirect url upon success.
     *
     * @param ?Request $request The request to be processed
     *
     * @return Request The request supposed to be processed
     */
    public function handleRequest(?Request $request = null)
    {
        $page = $this->getCurrentPage();

        if (($wizard = $this->findWizard($page)) !== null) {
            return $wizard->handleRequest($request);
        }

        if ($request === null) {
            $request = $page->getRequest();
        }

        $this->setupPage($page, $request);
        $requestData = $this->getRequestData($page, $request);
        if ($page->wasSent($requestData)) {
            if (($requestedPage = $this->getRequestedPage($requestData)) !== null) {
                $isValid = false;
                $direction = $this->getDirection($request);
                if ($direction === static::FORWARD && $page->isValid($requestData)) {
                    $isValid = true;
                    if ($this->isLastPage($page)) {
                        $this->setIsFinished();
                    }
                } elseif ($direction === static::BACKWARD) {
                    $page->populate($requestData);
                    $isValid = true;
                }

                if ($isValid) {
                    $pageData = & $this->getPageData();
                    $pageData[$page->getName()] = ConfigForm::transformEmptyValuesToNull($page->getValues());
                    $this->setCurrentPage($this->getNewPage($requestedPage, $page));
                    $page->getResponse()->redirectAndExit($page->getRedirectUrl());
                }
            } elseif ($page->getValidatePartial()) {
                $page->isValidPartial($requestData);
            } else {
                $page->populate($requestData);
            }
        } elseif (($pageData = $this->getPageData($page->getName())) !== null) {
            $page->populate($pageData);
        }

        return $request;
    }

    /**
     * Return the wizard for the given page or null if its not part of a wizard
     *
     * @param   Form    $page   The page to return its wizard for
     *
     * @return  Wizard|null
     */
    protected function findWizard(Form $page)
    {
        foreach ($this->getWizards() as $wizard) {
            if ($wizard->getPage($page->getName()) === $page) {
                return $wizard;
            }
        }
    }

    /**
     * Return this wizard's child wizards
     *
     * @return  array
     */
    protected function getWizards()
    {
        $wizards = array();
        foreach ($this->pages as $pageOrWizard) {
            if ($pageOrWizard instanceof self) {
                $wizards[] = $pageOrWizard;
            }
        }

        return $wizards;
    }

    /**
     * Return the request data based on given form's request method
     *
     * @param   Form        $page       The page to fetch the data for
     * @param   Request     $request    The request to fetch the data from
     *
     * @return  array
     */
    protected function getRequestData(Form $page, Request $request)
    {
        if (strtolower($request->getMethod()) === $page->getMethod()) {
            return $request->{'get' . ($request->isPost() ? 'Post' : 'Query')}();
        }

        return array();
    }

    /**
     * Return the name of the requested page
     *
     * @param   array   $requestData    The request's data
     *
     * @return  null|string             The name of the requested page or null in case no page has been requested
     */
    protected function getRequestedPage(array $requestData)
    {
        if ($this->parent) {
            return $this->parent->getRequestedPage($requestData);
        }

        if (isset($requestData[static::BTN_NEXT])) {
            return $requestData[static::BTN_NEXT];
        } elseif (isset($requestData[static::BTN_PREV])) {
            return $requestData[static::BTN_PREV];
        }
    }

    /**
     * Return the direction of this wizard using the given request
     *
     * @param ?Request $request The request to use
     *
     * @return int The direction @see Wizard::FORWARD @see Wizard::BACKWARD @see Wizard::NO_CHANGE
     */
    protected function getDirection(?Request $request = null)
    {
        if ($this->parent) {
            return $this->parent->getDirection($request);
        }

        $currentPage = $this->getCurrentPage();

        if ($request === null) {
            $request = $currentPage->getRequest();
        }

        $requestData = $this->getRequestData($currentPage, $request);
        if (isset($requestData[static::BTN_NEXT])) {
            return static::FORWARD;
        } elseif (isset($requestData[static::BTN_PREV])) {
            return static::BACKWARD;
        }

        return static::NO_CHANGE;
    }

    /**
     * Return the new page to set as current page
     *
     * Permission is checked by verifying that the requested page or its previous page has page data available.
     * The requested page is automatically permitted without any checks if the origin page is its previous
     * page or one that occurs later in order.
     *
     * @param   string  $requestedPage      The name of the requested page
     * @param   Form    $originPage         The origin page
     *
     * @return  Form                        The new page
     *
     * @throws  InvalidArgumentException    In case the requested page does not exist or is not permitted yet
     */
    protected function getNewPage($requestedPage, Form $originPage)
    {
        if ($this->parent) {
            return $this->parent->getNewPage($requestedPage, $originPage);
        }

        if (($page = $this->getPage($requestedPage)) !== null) {
            $permitted = true;

            $pages = $this->getPages();
            if (! $this->hasPageData($requestedPage) && ($index = array_search($page, $pages, true)) > 0) {
                $previousPage = $pages[$index - 1];
                if ($originPage === null || ($previousPage->getName() !== $originPage->getName()
                    && array_search($originPage, $pages, true) < $index)) {
                    $permitted = $this->hasPageData($previousPage->getName());
                }
            }

            if ($permitted) {
                return $page;
            }
        }

        throw new InvalidArgumentException(
            sprintf('"%s" is either an unknown page or one you are not permitted to view', $requestedPage)
        );
    }

    /**
     * Return the next or previous page based on the given one
     *
     * @param   Form    $page   The page to skip
     *
     * @return  Form
     */
    protected function skipPage(Form $page)
    {
        if ($this->parent) {
            return $this->parent->skipPage($page);
        }

        if ($this->hasPageData($page->getName())) {
            $pageData = & $this->getPageData();
            unset($pageData[$page->getName()]);
        }

        $pages = $this->getPages();
        if ($this->getDirection() === static::FORWARD) {
            $nextPage = $pages[array_search($page, $pages, true) + 1];
            $newPage = $this->getNewPage($nextPage->getName(), $page);
        } else { // $this->getDirection() === static::BACKWARD
            $previousPage = $pages[array_search($page, $pages, true) - 1];
            $newPage = $this->getNewPage($previousPage->getName(), $page);
        }

        return $newPage;
    }

    /**
     * Return whether the given page is this wizard's last page
     *
     * @param   Form    $page   The page to check
     *
     * @return  bool
     */
    protected function isLastPage(Form $page)
    {
        if ($this->parent) {
            return $this->parent->isLastPage($page);
        }

        $pages = $this->getPages();
        return $page->getName() === end($pages)->getName();
    }

    /**
     * Return whether all of this wizard's pages were visited by the user
     *
     * The base implementation just verifies that the very last page has page data available.
     *
     * @return  bool
     */
    public function isComplete()
    {
        $pages = $this->getPages();
        return $this->hasPageData($pages[count($pages) - 1]->getName());
    }

    /**
     * Set whether this wizard has been completed
     *
     * @param   bool    $state      Whether this wizard has been completed
     *
     * @return  $this
     */
    public function setIsFinished($state = true)
    {
        $this->getSession()->set('isFinished', $state);
        return $this;
    }

    /**
     * Return whether this wizard has been completed
     *
     * @return  bool
     */
    public function isFinished()
    {
        return $this->getSession()->get('isFinished', false);
    }

    /**
     * Return the overall page data or one for a particular page
     *
     * Note that this method returns by reference so in order to update the
     * returned array set this method's return value also by reference.
     *
     * @param   string  $pageName   The page for which to return the data
     *
     * @return  array
     */
    public function & getPageData($pageName = null)
    {
        $session = $this->getSession();

        if (false === isset($session->page_data)) {
            $session->page_data = array();
        }

        $pageData = & $session->getByRef('page_data');
        if ($pageName !== null) {
            $data = null;
            if (isset($pageData[$pageName])) {
                $data = & $pageData[$pageName];
            }

            return $data;
        }

        return $pageData;
    }

    /**
     * Return whether there is any data for the given page
     *
     * @param   string  $pageName   The name of the page to check
     *
     * @return  bool
     */
    public function hasPageData($pageName)
    {
        return $this->getPageData($pageName) !== null;
    }

    /**
     * Return a session to be used by this wizard
     *
     * @return  SessionNamespace
     */
    public function getSession()
    {
        if ($this->parent) {
            return $this->parent->getSession();
        }

        return Session::getSession()->getNamespace(get_class($this));
    }

    /**
     * Clear the session being used by this wizard
     */
    public function clearSession()
    {
        $this->getSession()->clear();
    }

    /**
     * Add buttons to the given page based on its position in the page-chain
     *
     * @param   Form    $page   The page to add the buttons to
     */
    protected function addButtons(Form $page)
    {
        $pages = $this->getPages();
        $index = array_search($page, $pages, true);
        if ($index === 0) {
            $page->addElement(
                'button',
                static::BTN_NEXT,
                array(
                    'class'         => 'control-button btn-primary',
                    'type'          => 'submit',
                    'value'         => $pages[1]->getName(),
                    'label'         => t('Next'),
                    'decorators'    => array('ViewHelper', 'Spinner')
                )
            );
        } elseif ($index < count($pages) - 1) {
            $page->addElement(
                'button',
                static::BTN_PREV,
                array(
                    'class'             => 'control-button',
                    'type'              => 'submit',
                    'value'             => $pages[$index - 1]->getName(),
                    'label'             => t('Back'),
                    'decorators'        => array('ViewHelper'),
                    'formnovalidate'    => 'formnovalidate'
                )
            );
            $page->addElement(
                'button',
                static::BTN_NEXT,
                array(
                    'class'         => 'control-button btn-primary',
                    'type'          => 'submit',
                    'value'         => $pages[$index + 1]->getName(),
                    'label'         => t('Next'),
                    'decorators'    => array('ViewHelper')
                )
            );
        } else {
            $page->addElement(
                'button',
                static::BTN_PREV,
                array(
                    'class'             => 'control-button',
                    'type'              => 'submit',
                    'value'             => $pages[$index - 1]->getName(),
                    'label'             => t('Back'),
                    'decorators'        => array('ViewHelper'),
                    'formnovalidate'    => 'formnovalidate'
                )
            );
            $page->addElement(
                'button',
                static::BTN_NEXT,
                array(
                    'class'         => 'control-button btn-primary',
                    'type'          => 'submit',
                    'value'         => $page->getName(),
                    'label'         => t('Finish'),
                    'decorators'    => array('ViewHelper')
                )
            );
        }

        $page->setAttrib('data-progress-element', static::PROGRESS_ELEMENT);
        $page->addElement(
            'note',
            static::PROGRESS_ELEMENT,
            array(
                'order'         => 99, // Ensures that it's shown on the right even if a sub-class adds another button
                'decorators'    => array(
                    'ViewHelper',
                    array('Spinner', array('id' => static::PROGRESS_ELEMENT))
                )
            )
        );

        $page->addDisplayGroup(
            array(static::BTN_PREV, static::BTN_NEXT, static::PROGRESS_ELEMENT),
            'buttons',
            array(
                'decorators' => array(
                    'FormElements',
                    new ElementDoubler(array(
                        'double'        => static::BTN_NEXT,
                        'condition'     => static::BTN_PREV,
                        'placement'     => ElementDoubler::PREPEND,
                        'attributes'    => array('tabindex' => -1, 'class' => 'double')
                    )),
                    array('HtmlTag', array('tag' => 'div', 'class' => 'buttons'))
                )
            )
        );
    }

    /**
     * Return the current page of this wizard with appropriate buttons being added
     *
     * @return  Form
     */
    public function getForm()
    {
        $form = $this->getCurrentPage();
        $form->create(); // Make sure that buttons are displayed at the very bottom
        $this->addButtons($form);
        return $form;
    }

    /**
     * Return the current page of this wizard rendered as HTML
     *
     * @return  string
     */
    public function __toString()
    {
        return (string) $this->getForm();
    }
}
