<?php

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

namespace Icinga\Module\Businessprocess\Controllers;

use Icinga\Date\DateFormatter;
use Icinga\Module\Businessprocess\BpConfig;
use Icinga\Module\Businessprocess\BpNode;
use Icinga\Module\Businessprocess\Forms\AddNodeForm;
use Icinga\Module\Businessprocess\Forms\EditNodeForm;
use Icinga\Module\Businessprocess\Node;
use Icinga\Module\Businessprocess\Renderer\Breadcrumb;
use Icinga\Module\Businessprocess\Renderer\Renderer;
use Icinga\Module\Businessprocess\Renderer\TileRenderer;
use Icinga\Module\Businessprocess\Renderer\TreeRenderer;
use Icinga\Module\Businessprocess\Simulation;
use Icinga\Module\Businessprocess\Storage\ConfigDiff;
use Icinga\Module\Businessprocess\Storage\LegacyConfigRenderer;
use Icinga\Module\Businessprocess\Web\Component\ActionBar;
use Icinga\Module\Businessprocess\Web\Component\RenderedProcessActionBar;
use Icinga\Module\Businessprocess\Web\Component\Tabs;
use Icinga\Module\Businessprocess\Web\Controller;
use Icinga\Util\Json;
use Icinga\Web\Notification;
use Icinga\Web\Url;
use Icinga\Web\Widget\Tabextension\DashboardAction;
use Icinga\Web\Widget\Tabextension\OutputFormat;
use ipl\Html\Form;
use ipl\Html\Html;
use ipl\Html\HtmlElement;
use ipl\Html\HtmlString;
use ipl\Html\TemplateString;
use ipl\Html\Text;
use ipl\Web\Control\SortControl;
use ipl\Web\FormElement\TermInput;
use ipl\Web\Widget\Link;
use ipl\Web\Widget\Icon;

class ProcessController extends Controller
{
    /** @var Renderer */
    protected $renderer;

    /**
     * Create a new Business Process Configuration
     */
    public function createAction()
    {
        $this->assertPermission('businessprocess/create');

        $title = $this->translate('Create a new Business Process');
        $this->setTitle($title);
        $this->controls()
            ->add($this->tabsForCreate()->activate('create'))
            ->add(Html::tag('h1', null, $title));

        $this->content()->add(
            $this->loadForm('bpConfig')
            ->setStorage($this->storage())
            ->setSuccessUrl('businessprocess/process/show')
            ->handleRequest()
        );
    }

    /**
     * Upload an existing Business Process Configuration
     */
    public function uploadAction()
    {
        $this->assertPermission('businessprocess/create');

        $title = $this->translate('Upload a Business Process Config file');
        $this->setTitle($title);
        $this->controls()
            ->add($this->tabsForCreate()->activate('upload'))
            ->add(Html::tag('h1', null, $title));

        $this->content()->add(
            $this->loadForm('BpUpload')
                ->setStorage($this->storage())
                ->setSuccessUrl('businessprocess/process/show')
                ->handleRequest()
        );
    }

    /**
     * Show a business process
     */
    public function showAction()
    {
        $bp = $this->loadModifiedBpConfig();
        $node = $this->getNode($bp);

        $bp->applyDbStates();

        $this->handleSimulations($bp);

        $this->setTitle($this->translate('Business Process "%s"'), $bp->getTitle());

        $renderer = $this->prepareRenderer($bp, $node);

        if (! $this->showFullscreen && ($node === null || ! $renderer->rendersImportedNode())) {
            if ($this->params->get('unlocked')) {
                $renderer->unlock();
            }

            if ($bp->isEmpty() && $renderer->isLocked()) {
                $this->redirectNow($this->url()->with('unlocked', true));
            }
        }

        $this->handleFormatRequest($bp, $node);

        $this->prepareControls($bp, $renderer);

        $this->tabs()->extend(new OutputFormat());

        $this->content()->add($this->showHints($bp, $renderer));
        $this->content()->add($this->showWarnings($bp));
        $this->content()->add($this->showErrors($bp));
        $this->content()->add($renderer);
        $this->loadActionForm($bp, $node);
        $this->setDynamicAutorefresh();
    }

    /**
     * Create a sort control and apply its sort specification to the given renderer
     *
     * @param Renderer $renderer
     * @param BpConfig $config
     *
     * @return SortControl
     */
    protected function createBpSortControl(Renderer $renderer, BpConfig $config): SortControl
    {
        $defaultSort = $this->session()->get('sort.default', $renderer->getDefaultSort());
        $options = [
            'display_name asc'  => $this->translate('Name'),
            'state desc'        => $this->translate('State')
        ];
        if ($config->getMetadata()->isManuallyOrdered()) {
            $options['manual asc'] = $this->translate('Manual');
        } elseif ($defaultSort === 'manual desc') {
            $defaultSort = $renderer->getDefaultSort();
        }

        $sortControl = SortControl::create($options)
            ->setDefault($defaultSort)
            ->setMethod('POST')
            ->setAttribute('name', 'bp-sort-control')
            ->on(Form::ON_SUCCESS, function (SortControl $sortControl) use ($renderer) {
                $sort = $sortControl->getSort();
                if ($sort === $renderer->getDefaultSort()) {
                    $this->session()->delete('sort.default');
                    $url = Url::fromRequest()->without($sortControl->getSortParam());
                } else {
                    $this->session()->set('sort.default', $sort);
                    $url = Url::fromRequest()->with($sortControl->getSortParam(), $sort);
                }

                $this->redirectNow($url);
            })->handleRequest($this->getServerRequest());

        $renderer->setSort($sortControl->getSort());
        $this->params->shift($sortControl->getSortParam());

        return $sortControl;
    }

    protected function prepareControls($bp, $renderer)
    {
        $controls = $this->controls();

        if ($this->showFullscreen) {
            $controls->getAttributes()->add('class', 'want-fullscreen');
            $controls->add(Html::tag(
                'a',
                [
                    'href'  => $this->url()->without('showFullscreen')->without('view'),
                    'title' => $this->translate('Leave full screen and switch back to normal mode')
                ],
                new Icon('down-left-and-up-right-to-center')
            ));
        }

        if (! ($this->showFullscreen || $this->view->compact)) {
            $controls->add($this->getProcessTabs($bp, $renderer));
            $controls->getAttributes()->add('class', 'separated');
        }

        $controls->add(Breadcrumb::create(clone $renderer));
        if (! $this->showFullscreen && ! $this->view->compact) {
            $controls->add(
                new RenderedProcessActionBar($bp, $renderer, $this->url())
            );
        }

        if (! ($this->showFullscreen || $this->view->compact)) {
            $controls->addHtml($this->createBpSortControl($renderer, $bp));
        }
    }

    protected function getNode(BpConfig $bp)
    {
        if ($nodeName = $this->params->get('node')) {
            return $bp->getNode($nodeName);
        } else {
            return null;
        }
    }

    protected function prepareRenderer($bp, $node)
    {
        if ($this->renderer === null) {
            if ($this->params->get('mode') === 'tree') {
                $renderer = new TreeRenderer($bp, $node);
            } else {
                $renderer = new TileRenderer($bp, $node);
            }
            $renderer->setUrl($this->url())
                ->setPath($this->params->getValues('path'));

            $this->renderer = $renderer;
        }

        return $this->renderer;
    }

    protected function getProcessTabs(BpConfig $bp, Renderer $renderer)
    {
        $tabs = $this->singleTab($bp->getTitle());
        if ($renderer->isLocked()) {
            $tabs->extend(new DashboardAction());
        }

        return $tabs;
    }

    protected function handleSimulations(BpConfig $bp)
    {
        $simulation = Simulation::fromSession($this->session());

        if ($this->params->get('dismissSimulations')) {
            Notification::success(
                sprintf(
                    $this->translate('%d applied simulation(s) have been dropped'),
                    $simulation->count()
                )
            );
            $simulation->clear();
            $this->redirectNow($this->url()->without('dismissSimulations')->without('unlocked'));
        }

        $bp->applySimulation($simulation);
    }

    protected function loadActionForm(BpConfig $bp, ?Node $node = null)
    {
        $action = $this->params->get('action');
        $form = null;
        if ($this->showFullscreen) {
            return;
        }

        $canEdit =  $bp->getMetadata()->canModify();

        if ($action === 'add' && $canEdit) {
            $form = (new AddNodeForm())
                ->setProcess($bp)
                ->setParentNode($node)
                ->setStorage($this->storage())
                ->setSession($this->session())
                ->on(AddNodeForm::ON_SUCCESS, function () {
                    $this->redirectNow(Url::fromRequest()->without('action'));
                })
                ->handleRequest($this->getServerRequest());

            if ($form->hasElement('children')) {
                /** @var TermInput $childrenElement */
                $childrenElement = $form->getElement('children');
                foreach ($childrenElement->prepareMultipartUpdate($this->getServerRequest()) as $update) {
                    if (! is_array($update)) {
                        $update = [$update];
                    }

                    $this->addPart(...$update);
                }
            }
        } elseif ($action === 'cleanup' && $canEdit) {
            $form = $this->loadForm('CleanupNode')
                ->setSuccessUrl(Url::fromRequest()->without('action'))
                ->setProcess($bp)
                ->setSession($this->session())
                ->handleRequest();
        } elseif ($action === 'editmonitored' && $canEdit) {
            $form = (new EditNodeForm())
                ->setProcess($bp)
                ->setNode($bp->getNode($this->params->get('editmonitorednode')))
                ->setParentNode($node)
                ->setSession($this->session())
                ->on(EditNodeForm::ON_SUCCESS, function () {
                    $this->redirectNow(Url::fromRequest()->without(['action', 'editmonitorednode']));
                })
                ->handleRequest($this->getServerRequest());
        } elseif ($action === 'delete' && $canEdit) {
            $form = $this->loadForm('DeleteNode')
                ->setSuccessUrl(Url::fromRequest()->without('action'))
                ->setProcess($bp)
                ->setNode($bp->getNode($this->params->get('deletenode')))
                ->setParentNode($node)
                ->setSession($this->session())
                ->handleRequest();
        } elseif ($action === 'edit' && $canEdit) {
            $form = $this->loadForm('Process')
                ->setSuccessUrl(Url::fromRequest()->without('action'))
                ->setProcess($bp)
                ->setNode($bp->getNode($this->params->get('editnode')))
                ->setSession($this->session())
                ->handleRequest();
        } elseif ($action === 'simulation') {
            $form = $this->loadForm('simulation')
                ->setSuccessUrl(Url::fromRequest()->without('action'))
                ->setNode($bp->getNode($this->params->get('simulationnode')))
                ->setSimulation(Simulation::fromSession($this->session()))
                ->handleRequest();
        } elseif ($action === 'move') {
            $successUrl = $this->url()->without(['action', 'movenode']);
            if ($this->params->get('mode') === 'tree') {
                // If the user moves a node from a subtree, the `node` param exists
                $successUrl->getParams()->remove('node');
            }

            if ($this->session()->get('sort.default')) {
                // If there's a default sort specification in the session, it can only be `display_name desc`,
                // as otherwise the user wouldn't be able to trigger this action. So it's safe to just define
                // descending manual order now.
                $successUrl->getParams()->add(SortControl::DEFAULT_SORT_PARAM, 'manual desc');
            }

            $form = $this->loadForm('MoveNode')
                ->setSuccessUrl($successUrl)
                ->setProcess($bp)
                ->setParentNode($node)
                ->setSession($this->session())
                ->setNode($bp->getNode($this->params->get('movenode')))
                ->handleRequest();
        }

        if ($form) {
            $this->content()->prepend(HtmlString::create((string) $form));
        }
    }

    protected function setDynamicAutorefresh()
    {
        if (! $this->isXhr()) {
            // This will trigger the very first XHR refresh immediately on page
            // load. Please not that this may hammer the server in case we would
            // decide to use autorefreshInterval for HTML meta-refreshes also.
            $this->setAutorefreshInterval(1);
            return;
        }

        if ($this->params->has('action')) {
            if ($this->params->get('action') !== 'add') {
                // The new add form uses the term input, which doesn't support value persistence across refreshes
                $this->setAutorefreshInterval(45);
            }
        } else {
            $this->setAutorefreshInterval(10);
        }
    }

    protected function showWarnings(BpConfig $bp)
    {
        if ($bp->hasWarnings()) {
            $ul = Html::tag('ul', array('class' => 'warning'));
            foreach ($bp->getWarnings() as $warning) {
                $ul->add(Html::tag('li')->setContent($warning));
            }

            return $ul;
        } else {
            return null;
        }
    }

    protected function showErrors(BpConfig $bp)
    {
        if ($bp->hasWarnings()) {
            $ul = Html::tag('ul', array('class' => 'error'));
            foreach ($bp->getErrors() as $msg) {
                $ul->add(Html::tag('li')->setContent($msg));
            }

            return $ul;
        } else {
            return null;
        }
    }

    protected function showHints(BpConfig $bp, Renderer $renderer)
    {
        $ul = Html::tag('ul', ['class' => 'error']);

        if (! $renderer->isLocked()) {
            $this->prepareMissingNodeLinks($ul);
        }

        foreach ($bp->getErrors() as $error) {
            $ul->addHtml(Html::tag('li', $error));
        }

        if ($bp->hasChanges()) {
            $li = Html::tag('li')->setSeparator(' ');
            $li->add(sprintf(
                $this->translate('This process has %d pending change(s).'),
                $bp->countChanges()
            ))->add(Html::tag(
                'a',
                [
                    'href' => Url::fromPath('businessprocess/process/config')
                        ->setParams($this->getRequest()->getUrl()->getParams())
                ],
                $this->translate('Store')
            ))->add(Html::tag(
                'a',
                ['href' => $this->url()->with('dismissChanges', true)],
                $this->translate('Dismiss')
            ));
            $ul->add($li);
        }

        if ($bp->hasSimulations()) {
            $li = Html::tag('li')->setSeparator(' ');
            $li->add(sprintf(
                $this->translate('This process shows %d simulated state(s).'),
                $bp->countSimulations()
            ))->add(Html::tag(
                'a',
                ['href' => $this->url()->with('dismissSimulations', true)],
                $this->translate('Dismiss')
            ));
            $ul->add($li);
        }

        if (! $renderer->isLocked() && $renderer->appliesCustomSorting()) {
            $ul->addHtml(Html::tag('li', null, [
                Text::create($this->translate('Drag&Drop disabled. Custom sort order applied.')),
                (new Form())
                    ->setAttribute('class', 'inline')
                    ->addElement('submitButton', SortControl::DEFAULT_SORT_PARAM, [
                        'label' => $this->translate('Reset to default'),
                        'value' => $renderer->getDefaultSort(),
                        'class' => 'link-button'
                    ])
                    ->addElement('hidden', 'uid', ['value' => 'bp-sort-control'])
            ])->setSeparator(' '));
        }

        if (! $ul->isEmpty()) {
            return $ul;
        } else {
            return null;
        }
    }

    protected function prepareMissingNodeLinks(HtmlElement $ul): void
    {
        $missing = array_keys($this->bp->getMissingChildren());
        if (! empty($missing)) {
            $missingLinkedNodes = null;
            foreach ($this->bp->getImportedNodes() as $process) {
                if ($process->hasMissingChildren()) {
                    $missingLinkedNodes = array_keys($process->getMissingChildren());
                    $link = Url::fromPath('businessprocess/process/show')
                        ->addParams(['config' => $process->getConfigName()]);

                    $ul->addHtml(Html::tag(
                        'li',
                        [
                            TemplateString::create(
                                tp(
                                    'Linked node %s has one missing child node: {{#link}}Show{{/link}}',
                                    'Linked node %s has %d missing child nodes: {{#link}}Show{{/link}}',
                                    count($missingLinkedNodes)
                                ),
                                $process->getAlias(),
                                count($missingLinkedNodes),
                                ['link' => new Link(null, (string) $link)]
                            )
                        ]
                    ));
                }
            }

            if (! empty($missingLinkedNodes)) {
                return;
            }

            $count = count($missing);
            if ($count > 10) {
                $missing = array_slice($missing, 0, 10);
                $missing[] = '...';
            }

            $link = Url::fromPath('businessprocess/process/show')
                ->addParams(['config' => $this->bp->getName(), 'action' => 'cleanup']);

            $ul->addHtml(Html::tag(
                'li',
                [
                    TemplateString::create(
                        tp(
                            '{{#link}}Cleanup{{/link}} one missing node: %2$s',
                            '{{#link}}Cleanup{{/link}} %d missing nodes: %s',
                            count($missing)
                        ),
                        ['link' => new Link(null, (string) $link)],
                        $count,
                        implode(', ', $missing)
                    )
                ]
            ));
        }
    }

    /**
     * Show the source code for a process
     */
    public function sourceAction()
    {
        $this->assertPermission('businessprocess/modify');

        $bp = $this->loadModifiedBpConfig();
        $this->view->showDiff = $showDiff = (bool) $this->params->get('showDiff', false);

        $this->view->source = LegacyConfigRenderer::renderConfig($bp);
        if ($this->view->showDiff) {
            $this->view->diff = ConfigDiff::create(
                $this->storage()->getSource($this->view->configName),
                $this->view->source
            );
            $title = sprintf(
                $this->translate('%s: Source Code Differences'),
                $bp->getTitle()
            );
        } else {
            $title = sprintf(
                $this->translate('%s: Source Code'),
                $bp->getTitle()
            );
        }

        $this->setTitle($title);
        $this->controls()
            ->add($this->tabsForConfig($bp)->activate('source'))
            ->add(Html::tag('h1', null, $title))
            ->add($this->createConfigActionBar($bp, $showDiff));

        $this->setViewScript('process/source');
    }

    /**
     * Download a process configuration file
     */
    public function downloadAction()
    {
        $this->assertPermission('businessprocess/modify');

        $config = $this->loadModifiedBpConfig();
        $response = $this->getResponse();
        $response->setHeader(
            'Content-Disposition',
            sprintf(
                'attachment; filename="%s.conf";',
                $config->getName()
            )
        );
        $response->setHeader('Content-Type', 'text/plain');

        echo LegacyConfigRenderer::renderConfig($config);
        $this->doNotRender();
    }

    /**
     * Modify a business process configuration
     */
    public function configAction()
    {
        $this->assertPermission('businessprocess/modify');

        $bp = $this->loadModifiedBpConfig();

        $title = sprintf(
            $this->translate('%s: Configuration'),
            $bp->getTitle()
        );
        $this->setTitle($title);
        $this->controls()
            ->add($this->tabsForConfig($bp)->activate('config'))
            ->add(Html::tag('h1', null, $title))
            ->add($this->createConfigActionBar($bp));

        $url = Url::fromPath('businessprocess/process/show')
            ->setParams($this->getRequest()->getUrl()->getParams());
        $this->content()->add(
            $this->loadForm('bpConfig')
                ->setProcess($bp)
                ->setStorage($this->storage())
                ->setSuccessUrl($url)
                ->handleRequest()
        );
    }

    protected function createConfigActionBar(BpConfig $config, $showDiff = false)
    {
        $actionBar = new ActionBar();

        if ($showDiff) {
            $params = array('config' => $config->getName());
            $actionBar->add(Html::tag(
                'a',
                [
                    'href'  => Url::fromPath('businessprocess/process/source', $params),
                    'title' => $this->translate('Show source code')
                ],
                [
                    new Icon('file-lines'),
                    $this->translate('Source'),
                ]
            ));
        } else {
            $params = array(
                'config'   => $config->getName(),
                'showDiff' => true
            );

            $actionBar->add(Html::tag(
                'a',
                [
                    'href'  => Url::fromPath('businessprocess/process/source', $params),
                    'title' => $this->translate('Highlight changes')
                ],
                [
                    new Icon('shuffle'),
                    $this->translate('Diff')
                ]
            ));
        }

        $actionBar->add(Html::tag(
            'a',
            [
                'href'      => Url::fromPath('businessprocess/process/download', ['config' => $config->getName()]),
                'target'    => '_blank',
                'title'     => $this->translate('Download process configuration')
            ],
            [
                new Icon('download'),
                $this->translate('Download')
            ]
        ));

        return $actionBar;
    }

    protected function tabsForShow()
    {
        return $this->tabs()->add('show', array(
            'label' => $this->translate('Business Process'),
            'url'   => $this->url()
        ));
    }

    /**
     * @return Tabs
     */
    protected function tabsForCreate()
    {
        return $this->tabs()->add('create', array(
            'label' => $this->translate('Create'),
            'url'   => 'businessprocess/process/create'
        ))->add('upload', array(
            'label' => $this->translate('Upload'),
            'url'   => 'businessprocess/process/upload'
        ));
    }

    protected function tabsForConfig(BpConfig $config)
    {
        $params = array(
            'config' => $config->getName()
        );

        $tabs = $this->tabs()->add('config', array(
            'label' => $this->translate('Process Configuration'),
            'url'   => Url::fromPath('businessprocess/process/config', $params)
        ));

        if ($this->params->get('showDiff')) {
            $params['showDiff'] = true;
        }

        $tabs->add('source', array(
            'label' => $this->translate('Source'),
            'url'   => Url::fromPath('businessprocess/process/source', $params)
        ));

        return $tabs;
    }

    protected function handleFormatRequest(BpConfig $bp, ?BpNode $node = null)
    {
        $desiredContentType = $this->getRequest()->getHeader('Accept');
        if ($desiredContentType === 'application/json') {
            $desiredFormat = 'json';
        } elseif ($desiredContentType === 'text/csv') {
            $desiredFormat = 'csv';
        } else {
            $desiredFormat = strtolower($this->params->get('format', 'html'));
        }

        switch ($desiredFormat) {
            case 'json':
                $response = $this->getResponse();
                $response
                    ->setHeader('Content-Type', 'application/json')
                    ->setHeader('Cache-Control', 'no-store')
                    ->setHeader(
                        'Content-Disposition',
                        'inline; filename=' . $this->getRequest()->getActionName() . '.json'
                    )
                    ->appendBody(Json::sanitize($node !== null ? $node->toArray() : $bp->toArray()))
                    ->sendResponse();
                exit;
            case 'csv':
                $csv = fopen('php://temp', 'w');

                fputcsv($csv, ['Path', 'Name', 'State', 'Since', 'In_Downtime'], escape: '\\');

                foreach ($node !== null ? $node->toArray(null, true) : $bp->toArray(true) as $node) {
                    $data = [$node['path'], $node['name']];

                    if (isset($node['state'])) {
                        $data[] = $node['state'];
                    }

                    if (isset($node['since'])) {
                        $data[] = DateFormatter::formatDateTime($node['since']);
                    }

                    if (isset($node['in_downtime'])) {
                        $data[] = $node['in_downtime'];
                    }

                    fputcsv($csv, $data, escape: '\\');
                }

                $response = $this->getResponse();
                $response
                    ->setHeader('Content-Type', 'text/csv')
                    ->setHeader('Cache-Control', 'no-store')
                    ->setHeader(
                        'Content-Disposition',
                        'attachment; filename=' . $this->getRequest()->getActionName() . '.csv'
                    )
                    ->sendHeaders();

                rewind($csv);

                fpassthru($csv);

                exit;
        }
    }
}
