<?php

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

namespace Icinga\Module\Businessprocess;

use Exception;
use Icinga\Application\Modules\Module;
use Icinga\Exception\IcingaException;
use Icinga\Exception\NotFoundError;
use Icinga\Module\Businessprocess\Exception\NestingError;
use Icinga\Module\Businessprocess\Modification\ProcessChanges;
use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
use Icinga\Module\Businessprocess\State\IcingaDbState;
use Icinga\Module\Businessprocess\State\MonitoringState;
use Icinga\Module\Businessprocess\Storage\LegacyStorage;
use Icinga\Module\Monitoring\Backend\MonitoringBackend;
use ipl\Sql\Connection as IcingaDbConnection;

class BpConfig
{
    public const SOFT_STATE = 0;

    public const HARD_STATE = 1;

    /**
     * Name of the configured monitoring backend
     *
     * @var string
     */
    protected $backendName;

    /**
     * Backend to retrieve states from
     *
     * @var MonitoringBackend|IcingaDbConnection
     */
    protected $backend;

    /**
     * @var LegacyStorage
     */
    protected $storage;

    /** @var  Metadata */
    protected $metadata;

    /**
     * Business process name
     *
     * @var string
     */
    protected $name;

    /**
     * Business process title
     *
     * @var string
     */
    protected $title;

    /**
     * State type, soft or hard
     *
     * @var int
     */
    protected $state_type;

    /**
     * Warnings, usually filled at process build time
     *
     * @var array
     */
    protected $warnings = array();

    /**
     * Errors, usually filled at process build time
     *
     * @var array
     */
    protected $errors = array();

    /**
     * All used node objects
     *
     * @var array
     */
    protected $nodes = array();

    /**
     * Root node objects
     *
     * @var array
     */
    protected $root_nodes = array();

    /**
     * Imported nodes
     *
     * @var ImportedNode[]
     */
    protected $importedNodes = [];

    /**
     * Imported configs
     *
     * @var BpConfig[]
     */
    protected $importedConfigs = [];

    /**
     * All host names { 'hostA' => true, ... }
     *
     * @var array
     */
    protected $hosts = array();

    /** @var bool Whether catchable errors should be thrown nonetheless */
    protected $throwErrors = false;

    protected $loopDetection = array();

    /**
     * Applied state simulation
     *
     * @var Simulation
     */
    protected $simulation;

    protected $changeCount = 0;

    protected $simulationCount = 0;

    /** @var ProcessChanges */
    protected $appliedChanges;

    /** @var bool Whether the config is faulty */
    protected $isFaulty = false;

    public function __construct(string $name = 'dummy')
    {
        $this->setName($name);
    }

    /**
     * Retrieve metadata for this configuration
     *
     * @return Metadata
     */
    public function getMetadata()
    {
        if ($this->metadata === null) {
            $this->metadata = new Metadata($this->name);
        }

        return $this->metadata;
    }

    /**
     * Apply database states
     *
     * @return void
     */
    public function applyDbStates(): void
    {
        if (
            Module::exists('icingadb')
            && (! $this->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())
        ) {
            IcingaDbState::apply($this);
        } else {
            MonitoringState::apply($this);
        }
    }

    /**
     * Set metadata
     *
     * @param Metadata $metadata
     *
     * @return $this
     */
    public function setMetadata(Metadata $metadata)
    {
        $this->metadata = $metadata;
        return $this;
    }

    /**
     * Apply pending process changes
     *
     * @param ProcessChanges $changes
     *
     * @return $this
     */
    public function applyChanges(ProcessChanges $changes)
    {
        $this->applyDbStates();

        $cnt = 0;
        foreach ($changes->getChanges() as $change) {
            $cnt++;
            $change->applyTo($this);
        }
        $this->changeCount = $cnt;

        $this->appliedChanges = $changes;

        return $this;
    }

    /**
     * Apply a state simulation
     *
     * @param Simulation $simulation
     *
     * @return $this
     */
    public function applySimulation(Simulation $simulation)
    {
        $cnt = 0;

        foreach ($simulation->simulations() as $node => $s) {
            if (! $this->hasNode($node)) {
                continue;
            }
            $cnt++;
            $this->getNode($node)
                 ->setState($s->state)
                 ->setAck($s->acknowledged)
                 ->setDowntime($s->in_downtime)
                 ->setMissing(false);
        }

        $this->simulationCount = $cnt;

        return $this;
    }

    /**
     * Number of applied changes
     *
     * @return int
     */
    public function countChanges()
    {
        return $this->changeCount;
    }

    /**
     * Whether changes have been applied to this configuration
     *
     * @return bool
     */
    public function hasChanges()
    {
        return $this->countChanges() > 0;
    }

    /**
     * @param $name
     *
     * @return $this
     */
    public function setName($name)
    {
        $this->name = $name;
        return $this;
    }

    /**
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @return string
     */
    public function getHtmlId()
    {
        return 'businessprocess-' . preg_replace('/[\r\n\t\s]/', '_', $this->getName());
    }

    public function setTitle($title)
    {
        $this->title = $title;
        return $this;
    }

    public function getTitle()
    {
        return $this->getMetadata()->getTitle();
    }

    public function hasTitle()
    {
        return $this->getMetadata()->has('Title');
    }

    public function getBackendName()
    {
        return $this->getMetadata()->get('Backend');
    }

    public function hasBackendName()
    {
        return $this->getMetadata()->has('Backend');
    }

    public function setBackend($backend)
    {
        $this->backend = $backend;
        return $this;
    }

    public function getBackend()
    {
        if ($this->backend === null) {
            if (
                Module::exists('icingadb')
                && (! $this->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())
            ) {
                $this->backend = IcingaDbObject::fetchDb();
            } else {
                $this->backend = MonitoringBackend::instance(
                    $this->getBackendName()
                );
            }
        }

        return $this->backend;
    }

    public function isReferenced()
    {
        foreach ($this->storage()->listProcessNames() as $bpName) {
            if ($bpName !== $this->getName()) {
                $bp = $this->storage()->loadProcess($bpName);
                foreach ($bp->getImportedNodes() as $importedNode) {
                    if ($importedNode->getConfigName() === $this->getName()) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    public function hasBackend()
    {
        return $this->backend !== null;
    }

    public function hasBeenChanged()
    {
        return false;
    }

    public function hasSimulations()
    {
        return $this->countSimulations() > 0;
    }

    public function countSimulations()
    {
        return $this->simulationCount;
    }

    public function clearAppliedChanges()
    {
        if ($this->appliedChanges !== null) {
            $this->appliedChanges->clear();
        }
        return $this;
    }

    public function getStateType()
    {
        if ($this->state_type === null) {
            if ($this->getMetadata()->has('Statetype')) {
                switch ($this->getMetadata()->get('Statetype')) {
                    case 'hard':
                        $this->state_type = self::HARD_STATE;
                        break;
                    case 'soft':
                        $this->state_type = self::SOFT_STATE;
                        break;
                }
            } else {
                $this->state_type = self::HARD_STATE;
            }
        }

        return $this->state_type;
    }

    public function useSoftStates()
    {
        $this->state_type = self::SOFT_STATE;
        return $this;
    }

    public function useHardStates()
    {
        $this->state_type = self::HARD_STATE;
        return $this;
    }

    public function usesSoftStates()
    {
        return $this->getStateType() === self::SOFT_STATE;
    }

    public function usesHardStates()
    {
        return $this->getStateType() === self::HARD_STATE;
    }

    public function addRootNode($name)
    {
        $this->root_nodes[$name] = $this->getNode($name);
        return $this;
    }

    public function removeRootNode($name)
    {
        if ($this->isRootNode($name)) {
            unset($this->root_nodes[$name]);
        }

        return $this;
    }

    public function isRootNode($name)
    {
        return array_key_exists($name, $this->root_nodes);
    }

    /**
     * @return BpNode[]
     */
    public function getChildren()
    {
        return $this->getRootNodes();
    }

    /**
     * @return int
     */
    public function countChildren()
    {
        return count($this->root_nodes);
    }

    /**
     * @return BpNode[]
     */
    public function getRootNodes()
    {
        return $this->root_nodes;
    }

    public function listRootNodes()
    {
        return array_keys($this->root_nodes);
    }

    public function getNodes()
    {
        return $this->nodes;
    }

    public function hasNode($name)
    {
        if (array_key_exists($name, $this->nodes)) {
            return true;
        } elseif ($name[0] === '@') {
            list($configName, $nodeName) = preg_split('~:\s*~', substr($name, 1), 2);
            return $this->getImportedConfig($configName)->hasNode($nodeName);
        }

        return false;
    }

    public function hasRootNode($name)
    {
        return array_key_exists($name, $this->root_nodes);
    }

    public function createService($host, $service)
    {
        $node = new ServiceNode(
            (object) array(
                'hostname' => $host,
                'service'  => $service
            )
        );
        $node->setBpConfig($this);
        $this->nodes[$node->getName()] = $node;
        $this->hosts[$host] = true;
        return $node;
    }

    public function createHost($host)
    {
        $node = new HostNode((object) array('hostname' => $host));
        $node->setBpConfig($this);
        $this->nodes[$node->getName()] = $node;
        $this->hosts[$host] = true;
        return $node;
    }

    public function calculateAllStates()
    {
        foreach ($this->getRootNodes() as $node) {
            $node->getState();
        }

        return $this;
    }

    public function clearAllStates()
    {
        foreach ($this->getBpNodes() as $node) {
            $node->clearState();
        }

        return $this;
    }

    public function listInvolvedHostNames(&$usedConfigs = null)
    {
        $hosts = $this->hosts;
        if (! empty($this->importedNodes)) {
            $usedConfigs[$this->getName()] = true;
            foreach ($this->importedNodes as $node) {
                if (isset($usedConfigs[$node->getConfigName()])) {
                    continue;
                }

                $hosts += array_flip($node->getBpConfig()->listInvolvedHostNames($usedConfigs));
            }
        }

        return array_keys($hosts);
    }

    /**
     * Create and attach a new process (BpNode)
     *
     * @param string $name     Process name
     * @param string $operator Operator (defaults to &)
     *
     * @return BpNode
     */
    public function createBp($name, $operator = '&')
    {
        $node = new BpNode((object) array(
            'name'        => $name,
            'operator'    => $operator,
            'child_names' => array(),
        ));
        $node->setBpConfig($this);

        $this->addNode($name, $node);
        return $node;
    }

    public function createMissingBp($name)
    {
        return $this->createBp($name)->setMissing();
    }

    public function getMissingChildren()
    {
        $missing = array();
        foreach ($this->getRootNodes() as $root) {
            $missing += $root->getMissingChildren();
        }

        return $missing;
    }

    public function createImportedNode($config, $name = null)
    {
        $params = (object) array('configName' => $config);
        if ($name !== null) {
            $params->node = $name;
        }

        $node = new ImportedNode($this, $params);
        $this->importedNodes[$node->getName()] = $node;
        $this->nodes[$node->getName()] = $node;
        return $node;
    }

    public function getImportedNodes()
    {
        return $this->importedNodes;
    }

    public function getImportedConfig($name)
    {
        if (! isset($this->importedConfigs[$name])) {
            try {
                $import = $this->storage()->loadProcess($name);
            } catch (Exception $e) {
                $import = (new static())
                    ->setName($name)
                    ->setFaulty();
            }

            if ($this->usesSoftStates()) {
                $import->useSoftStates();
            } else {
                $import->useHardStates();
            }

            $this->importedConfigs[$name] = $import;
        }

        return $this->importedConfigs[$name];
    }

    public function listInvolvedConfigs(&$configs = null)
    {
        if ($configs === null) {
            $configs[$this->getName()] = $this;
        }

        foreach ($this->importedNodes as $node) {
            if (! isset($configs[$node->getConfigName()])) {
                $config = $node->getBpConfig();
                $configs[$node->getConfigName()] = $config;
                $config->listInvolvedConfigs($configs);
            }
        }

        return $configs;
    }

    /**
     * @return LegacyStorage
     */
    protected function storage()
    {
        if ($this->storage === null) {
            $this->storage = LegacyStorage::getInstance();
        }

        return $this->storage;
    }

    /**
     * @param   string  $name
     * @return  MonitoredNode|BpNode
     * @throws  Exception
     */
    public function getNode($name)
    {
        if ($name === '__unbound__') {
            return $this->getUnboundBaseNode();
        }

        if (array_key_exists($name, $this->nodes)) {
            return $this->nodes[$name];
        }

        if ($name[0] === '@') {
            list($configName, $nodeName) = preg_split('~:\s*~', substr($name, 1), 2);
            return $this->getImportedConfig($configName)->getNode($nodeName);
        }

        // Fallback: if it is a service, create an empty one:
        $this->warn(sprintf('The node "%s" doesn\'t exist', $name));

        [$name, $suffix] = self::splitNodeName($name);
        if ($suffix !== null) {
            if ($suffix === 'Hoststatus') {
                return $this->createHost($name);
            } else {
                return $this->createService($name, $suffix);
            }
        }

        throw new Exception(
            sprintf('The node "%s" doesn\'t exist', $name)
        );
    }

    /**
     * @return BpNode
     */
    public function getUnboundBaseNode()
    {
        // Hint: state is useless here, but triggers parent/child "calculation"
        //       This is an ugly workaround and should be made obsolete
        $this->calculateAllStates();

        $names = array_keys($this->getUnboundNodes());
        $bp = new BpNode((object) array(
            'name' => '__unbound__',
            'operator' => '&',
            'child_names' => $names
        ));
        $bp->setBpConfig($this);
        $bp->setAlias($this->translate('Unbound nodes'));
        return $bp;
    }

    /**
     * @param $name
     * @return BpNode
     *
     * @throws NotFoundError
     */
    public function getBpNode($name)
    {
        if ($this->hasBpNode($name)) {
            return $this->nodes[$name];
        }

        $msg = $this->isFaulty()
            ? sprintf(
                t('Trying to import node "%s" from faulty config file "%s.conf"'),
                self::unescapeName($name),
                $this->getName()
            )
            : sprintf(t('Trying to access a missing business process node "%s"'), $name);

        throw new NotFoundError($msg);
    }

    /**
     * @param $name
     *
     * @return bool
     */
    public function hasBpNode($name)
    {
        return array_key_exists($name, $this->nodes)
            && $this->nodes[$name] instanceof BpNode;
    }

    /**
     * Set the state for a specific node
     *
     * @param string $name  Node name
     * @param int    $state Desired state
     *
     * @return $this
     */
    public function setNodeState($name, $state)
    {
        $this->getNode($name)->setState($state);
        return $this;
    }

    /**
     * Add the given node to the given BpNode
     *
     * @param $name
     * @param BpNode $node
     *
     * @return $this
     */
    public function addNode($name, BpNode $node)
    {
        if (array_key_exists($name, $this->nodes)) {
            $this->warn(
                sprintf(
                    mt('businessprocess', 'Node "%s" has been defined twice'),
                    $name
                )
            );
        }

        $this->nodes[$name] = $node;

        if ($node->getDisplay() > 0) {
            if (! $this->isRootNode($name)) {
                $this->addRootNode($name);
            }
        } else {
            if ($this->isRootNode($name)) {
                $this->removeRootNode($name);
            }
        }


        return $this;
    }

    /**
     * Remove all occurrences of a specific node by name
     *
     * @param $name
     */
    public function removeNode($name)
    {
        unset($this->nodes[$name]);
        if (array_key_exists($name, $this->root_nodes)) {
            unset($this->root_nodes[$name]);
        }

        foreach ($this->getBpNodes() as $node) {
            if ($node->hasChild($name)) {
                $node->removeChild($name);
            }
        }
    }

    /**
     * Get all business process nodes
     *
     * @return BpNode[]
     */
    public function getBpNodes()
    {
        $nodes = array();

        foreach ($this->nodes as $node) {
            if ($node instanceof BpNode) {
                $nodes[$node->getName()] = $node;
            }
        }

        return $nodes;
    }

    /**
     * List all business process node names
     *
     * @return array
     */
    public function listBpNodes()
    {
        $nodes = array();

        foreach ($this->getBpNodes() as $name => $node) {
            $alias = $node->getAlias();
            $nodes[$name] = $name === $alias ? $name : sprintf('%s (%s)', $alias, $node);
        }

        return $nodes;
    }

    /**
     * All business process nodes defined in this config but not
     * assigned to any parent
     *
     * @return BpNode[]
     */
    public function getUnboundNodes()
    {
        $nodes = array();

        foreach ($this->getBpNodes() as $name => $node) {
            if ($node->hasParents()) {
                continue;
            }

            if ($node->getDisplay() === 0) {
                $nodes[$name] = $node;
            }
        }

        return $nodes;
    }

    /**
     * @return bool
     */
    public function hasWarnings()
    {
        return ! empty($this->warnings);
    }

    /**
     * @return array
     */
    public function getWarnings()
    {
        return $this->warnings;
    }

    /**
     * @return bool
     */
    public function hasErrors()
    {
        return ! empty($this->errors) || $this->isEmpty();
    }

    /**
     * @return array
     */
    public function getErrors()
    {
        $errors = $this->errors;
        if ($this->isEmpty()) {
            $errors[] = sprintf(
                $this->translate(
                    'No business process nodes for "%s" have been defined yet'
                ),
                $this->getTitle()
            );
        }
        return $errors;
    }

    /**
     * Translation helper
     *
     * @param $msg
     *
     * @return mixed|string
     */
    public function translate($msg)
    {
        return mt('businessprocess', $msg);
    }

    /**
     * Add a message to our warning stack
     *
     * @param $msg
     */
    protected function warn($msg)
    {
        $args = func_get_args();
        array_shift($args);
        $this->warnings[] = vsprintf($msg, $args);
    }

    /**
     * @param string $msg,...
     *
     * @return $this
     *
     * @throws IcingaException
     */
    public function addError($msg)
    {
        $args = func_get_args();
        array_shift($args);
        if (! empty($args)) {
            $msg = vsprintf($msg, $args);
        }
        if ($this->throwErrors) {
            throw new IcingaException($msg);
        }

        if (! in_array($msg, $this->errors)) {
            $this->errors[] = $msg;
        }

        return $this;
    }

    /**
     * Decide whether errors should be thrown or collected
     *
     * @param bool $throw
     *
     * @return $this
     */
    public function throwErrors($throw = true)
    {
        $this->throwErrors = $throw;
        return $this;
    }

    /**
     * Begin loop detection for the given name
     *
     * Will throw a NestingError in case this node will be met again below itself
     *
     * @param $name
     *
     * @throws NestingError
     */
    public function beginLoopDetection($name)
    {
        // echo "Begin loop $name\n";
        if (array_key_exists($name, $this->loopDetection)) {
            $loop = array_keys($this->loopDetection);
            $loop[] = $name;
            $this->loopDetection = array();
            throw new NestingError('Loop detected: %s', implode(' -> ', $loop));
        }

        $this->loopDetection[$name] = true;
    }

    /**
     * Remove the given name from the loop detection stack
     *
     * @param $name
     */
    public function endLoopDetection($name)
    {
        // echo "End loop $this->name\n";
        unset($this->loopDetection[$name]);
    }

    /**
     * Whether this configuration has any Nodes
     *
     * @return bool
     */
    public function isEmpty()
    {
        // This is faster
        if (! empty($this->root_nodes)) {
            return false;
        }

        return count($this->listBpNodes()) === 0;
    }

    /**
     * Export the config to array
     *
     * @param   bool    $flat   If false, children will be added to the array key children, else the array will be flat
     *
     * @return  array
     */
    public function toArray($flat = false)
    {
        $data = [
            'name' => $this->getTitle(),
            'path' => $this->getTitle()
        ];

        $children = [];

        foreach ($this->getChildren() as $node) {
            if ($flat) {
                $children = array_merge($children, $node->toArray($data, $flat));
            } else {
                $children[] = $node->toArray($data, $flat);
            }
        }

        if ($flat) {
            $data = [$data];

            if (! empty($children)) {
                $data = array_merge($data, $children);
            }
        } else {
            $data['children'] = $children;
        }

        return $data;
    }

    /**
     * Escape the given node name
     *
     * @param string $name
     *
     * @return string
     */
    public static function escapeName(string $name): string
    {
        return preg_replace('/((?<!\\\\);)/', '\\\\$1', $name);
    }

    /**
     * Unescape the given node name
     *
     * @param string $name
     *
     * @return string
     */
    public static function unescapeName(string $name): string
    {
        return str_replace('\\;', ';', $name);
    }

    /**
     * Join the given two name parts together
     *
     * The used separator is the semicolon. If a semicolon exists in either part, it's escaped.
     *
     * @param string $name
     * @param ?string $suffix
     *
     * @return string
     */
    public static function joinNodeName(string $name, ?string $suffix = null): string
    {
        return self::escapeName($name) . ($suffix ? ";$suffix" : '');
    }

    /**
     * Split the given node name into two parts
     *
     * The first part is always a string, with any semicolons unescaped.
     * The second part may be null or a string otherwise.
     *
     * @param string $nodeName
     *
     * @return array
     */
    public static function splitNodeName(string $nodeName): array
    {
        $parts = preg_split('/(?<!\\\\);/', $nodeName, 2);
        $parts[0] = self::unescapeName($parts[0]);

        return array_pad($parts, 2, null);
    }

    /**
     * Set whether the config is faulty
     *
     * @param bool $isFaulty
     *
     * @return $this
     */
    public function setFaulty(bool $isFaulty = true): self
    {
        $this->isFaulty = $isFaulty;

        return $this;
    }

    /**
     * Get whether the config is faulty
     *
     * @return bool
     */
    public function isFaulty(): bool
    {
        return $this->isFaulty;
    }
}
