<?php 

namespace NPS\DependencyInjection;

use Symfony\Component\Process\InputStream;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
use NPS\Exception\NoConsultationException;
use NPS\DependencyInjection\IKnowledgeBaseInfoProvider;
use NPS\Model\TraversableXMLElement as XML;
use Symfony\Component\Process\Exception\RuntimeException;
use NPS\Etc\TMessage;
use Psr\Log\LoggerInterface as Logger;
use NPS\Etc\Strings;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use NPS\Etc\Files;

// Register a shutdown function to make sure the process is always terminated
register_shutdown_function(function(){
    NPSBridge::forceProcessTermination();
});

/**
 * Handles communication with an external NPS process
 * @author Lukas Korinek
 */
final class NPSBridge implements IExpertSystemBridge, IKnowledgeBaseInfoProvider
{
    use TMessage;

    // Statics

    /**
     * @var Process $process Handle to the NPS process
     */
    private static $process;

    /**
     * @var InputStream $processInput Input stream for the asynchronous process
     */
    private static $processInput;

    /**
     * Forces the NPS process to be terminated at all costs
     * @return bool True on successful termination
     */
    public static function forceProcessTermination()
    {
        if(!self::$process)
        {
            return true;
        }

        if(self::$processInput)
        {
            self::$processInput->close();
        }
    
        self::$process->stop(0);        
        if(self::$process->isTerminated())
        {
            self::$process = null;
            self::$processInput = null;

            return true;
        }

        return false;
    }


    /////////////////////////////////////////////////////////////
    /////////////////////////////////////////////////////////////
    /////////////////////////////////////////////////////////////

    /**
     * @var string $path Path to the executable of NPS
     */
    private $path;

    /**
     * @var Logger $logger
     */
    private $logger;

    /**
     * @var string $defaultLanguage
     */
    private $defaultLanguage;

    /**
     * @var string $currentBasePath 
     */
    private $currentBasePath;

    /**
     * @var array $currentSupportedLanguages
     */
    private $currentSupportedLanguages;

    /**
     * @var string $currentLanguage
     */
    private $currentLanguage;

    /**     
     * @param Logger $npsLogger
     * @param RequestStack $requestStack 
     * @param string $npsPath Path to the NPS application executable
     */
    public function __construct(Logger $npsLogger, RequestStack $requestStack, $npsPath)
    {
        $this->path = Files::normalizeFile($npsPath, Files::EXECUTABLE);

        $request = $requestStack->getCurrentRequest();
        $this->defaultLanguage = $request ? $request->getLocale() : 'en';
        $this->logger = $npsLogger;

        $this->currentBasePath = null;
        $this->currentSupportedLanguages = null;
        $this->currentLanguage = null;
    }

    /** 
     * Destruct the process also
     */
    public function __destruct()
    {
        $this->terminateProcess();
    }

    /**
     * @inheritdoc
     */
    public function setDefaultLanguage($language)
    {
        $this->defaultLanguage = $language;
    }

    /**
     * @inheritdoc
     */
    public function getDefaultLanguage()
    {
        return $this->defaultLanguage;
    }

    /**
     * @inheritdoc
     */
    public function getKnowledgeBaseName($path)
    {
        if(
            !$this->loadBase($path) ||
            $this->setLanguage($this->defaultLanguage) === false ||
            !$this->baseGetInfo($info)
        )
        {
            return null;
        }

        $name = $info->getChild('name');
        $content = $name->getChild('content');

        return str_replace("\t", "", trim($content ? $content->getText() : $name->getText()));
    }

    /**
     * @inheritdoc
     */
    public function getKnowledgeBaseDescription($path)
    {
        if(
            !$this->loadBase($path) ||
            $this->setLanguage($this->defaultLanguage) === false ||
            !$this->baseGetInfo($info)
        )
        {
            return null;
        }
    
        if(!$description = $info->getChild('description'))
        {
            return null;
        }

        if(!$content = $description->getChild('content'))
        {
            return null;
        }

        return str_replace("\t", "", trim($content->getText()));
    }

    /**
     * @inheritdoc
     */
    public function getKnowledgeBaseLanguages($path)
    {
        if(!$this->loadBase($path))
        {
            return null;
        }

        return $this->getSupportedLanguages();
    }

    /**
     * @inheritdoc
     */
    public function startConsultation($path)
    {
        if(!(
            $this->loadBase($path) &&
            $this->setLanguage($this->defaultLanguage) !== false &&
            $this->baseResetBase()
        ))
        {
            return false;
        }

        return true;
    }

    /**
     * @inheritdoc
     */
    public function loadConsultationState(array $data)
    {
        $this->currentLanguage = null;

        if(
            
            !$this->loadBase($data['base']) || 
            !$this->baseSetState(true, XML::fromString($data['internal'])) ||
            $this->setLanguage($this->defaultLanguage) === false
        )
        {
            return false;
        }
        
        return true;
    }

    /**
     * @inheritdoc
     */
    public function saveConsultationState()
    {
        if(!$this->baseGetState(true, $state))
        {
            return null;
        }
        
        return [
            'base' => $this->currentBasePath, // System doesn't return the same path that was used to create the consultation
            'started' => $state->getChild('state')->getAttribute('start_time'),
            'language' => $state->getChild('state')->getAttribute('current_language'),
            'offset' => $state->getChild('state')->getAttribute('offset'),
            'internal' => (string)$state->getChild('state')
        ];
    }

    /**
     * @inheritdoc
     */
    public function getLanguage()
    {
        if(!$this->baseGetLanguage($output))
        {
            return null;
        }

        return $output->getChild('current_language')->getText();
    }

    /**
     * @inheritdoc
     */
    public function getSupportedLanguages()
    {
        if(!is_array($this->currentSupportedLanguages))
        {
            if(!$this->baseGetLanguage($output))
            {
                return null;
            }

            $this->currentSupportedLanguages = array_map(function($child)
            {
                return $child->getText();
            }, $output->getChild('languages')->getChildren('language'));
        }
        
        return $this->currentSupportedLanguages;
    }

    /**
     * @inheritdoc
     */
    public function setLanguage($language)
    {
        if($this->currentLanguage == $language)
        {
            return true;
        }

        if(!($supported = $this->getSupportedLanguages()))
        {
            return false;
        }
        
        $language = explode('-', $language)[0];
        foreach($supported as $lang)
        {            
            // Symfony identifier - "en"
            // Knowledge base ISO identifier - "en-GB" or "en-US"
            if($language == explode('-', $lang)[0])
            {
                $this->currentLanguage = $lang;
                return $this->baseSetLanguage($lang);
            }
        }

        // Set the language to one of the supported languages
        $lang = reset($supported);
        $this->currentLanguage = $lang;
        $this->baseSetLanguage($lang);
        return null;
    }

    /**
     * @inheritdoc
     */
    public function getQuestion()
    {
        if(!$this->baseGetQuery($output))
        {
            return null;
        }

        return $output->getChild('question');
    }

    /**
     * @inheritdoc
     */
    public function setAnswer($answer)
    {
        return $this->baseSetAnswer($answer);
    }

    /**
     * @inheritdoc
     */
    public function getHypothesis()
    {
        if(!$this->baseGetHypothesis($output))
        {
            return null;
        }
        
        return $output->getChild('hypoteses')->getChildren('hypotesis');
    }

    /**
     * @inheritdoc
     */
    public function back()
    {
        return $this->baseGoBack();
    }

    /**
     * @inheritdoc
     */
    public function forward()
    {
        return $this->baseGoAhead();
    }

    /**
     * @inheritdoc
     */
    public function endConsultation()
    {
        return $this->cmdCloseBase();
    }

    /**
     * @inheritdoc
     */
    public function isEnded()
    {
        if(!$this->baseGetQuery($result))
        {
            return null;
        }

        return $result->getAttribute('id') == 100;
    }

    /**
     * Converts between old and new base formats
     * @param string $source Base to convert, absolute path
     * @param string $destination Destination file
     * @return bool
     */
    public function convertBaseFormat($source, $destination)
    {
        return $this->cmdConvertBase($source, $destination);
    }

    ////////////////////////////////////////////////////////////////////////////
    // Optimization functions

    /**
     * Loads the knowledge base if necessary
     * @param string $path
     * @return bool
     */
    private function loadBase($path)
    {
        if($this->currentBasePath == $path)
        {
            return true;
        }

        if(!$this->cmdLoadBase($path))
        {
            return false;
        }

        $this->currentLanguage = null;
        $this->currentSupportedLanguages = null;

        return true;
    }

    ////////////////////////////////////////////////////////////////////////////
    // NPS functions implementation

    /**
     * Loads a knowledge base
     * @param string $path Path to the knowledge base file
     * @return bool
     */ 
    private function cmdLoadBase($path)
    {
        $this->currentBasePath = $path;

        return $this->command(
            XML::create('cmd', ['type' => 'load_base', 'src' => 'file'], $path)
        );
    }

    /**
     * Closes an active knowledge base
     * @return bool
     */
    private function cmdCloseBase()
    {
        return $this->command(
            XML::create('cmd', ['type' => 'close_base'])
        );
    }

    /**
     * Converts a knowledge base from old format to new format
     * @param string $source Source file to convert
     * @param string $destination Destination file to convert
     * @return bool
     */
    private function cmdConvertBase($source, $destination)
    {
        return $this->command(
            XML::create('cmd', ['type' => 'convert_base'])
            ->addChild(
                XML::create('src', ['type' => 'file'], $source)
            )
            ->addChild(
                XML::create('dst', ['type' => 'file'], $destination)
            )
        );
    }

    /**
     * Closes the NPS application
     * @return bool
     */
    private function cmdExit()
    {
        return $this->command(
            XML::create('cmd', ['type' => 'exit'])
        );
    }

    /**
     * Sets the consultation state
     * @param bool $direct Whether to restore from a file or a state structure
     * @param string|XML $state Either state XML structure or path to a file
     * @return bool
     */
    private function baseSetState($direct = false, $state)
    {
        $xml = XML::create('base', ['type' => 'set_state', 'src' => $direct ? 'direct' : 'file']);

        return $this->command(
            $direct ? $xml->addChild($state) : $xml->setText($state)
        );
    }

    /**
     * Gets consultation state structure
     * @param bool $direct Whether to return or store in a file
     * @param string $state Path to the file or a data structure in which to store the data
     * @return bool
     */
    private function baseGetState($direct = false, &$state = null)
    {
        return $this->command(
            XML::create('base', ['type' => 'get_state', 'dst' => $direct ? 'direct' : 'file'], !$direct ? $path : ''), $state
        );
    }

    /**
     * Gets current language and lists supported ones
     * @param XML $output
     * @return bool
     */
    private function baseGetLanguage(XML &$output = null)
    {
        return $this->command(
            XML::create('base', ['type' => 'get_language'])
            , $output
        );
    }

    /**
     * Sets the language of the base
     * @param mixed $langId
     * @return bool
     */
    private function baseSetLanguage($langId)
    {
        return $this->command(
            XML::create('base', ['type' => 'set_language'], $langId)
            , $output
        );
    }

    /**
     * Gets info
     * @param XML $output 
     * @return bool
     */
    private function baseGetInfo(XML &$output = null)
    {
        return $this->command(
            XML::create('base', ['type' => 'get_info'])
            , $output
        );
    }

    /**
     * Gets hypothesis
     * @param XML $output 
     * @return bool
     */
    private function baseGetHypothesis(XML &$output = null)
    {
        return $this->command(
            XML::create('base', ['type' => 'get_hypotesis'])
            , $output
        );
    }

    /**
     * Gets the current query
     * @param XML $output
     * @return bool
     */
    private function baseGetQuery(XML &$output = null)
    {
        return $this->command(
            XML::create('base', ['type' => 'get_query'])
            , $output
        );
    }

    /**
     * Sets the answer of a query
     * @param mixed $answerId
     * @return bool
     */
    private function baseSetAnswer($answerId)
    {
        return $this->command(
            XML::create('base', ['type' => 'set_answer'], $answerId)
        );
    }

    /**
     * Resets current query and all backed queries
     * @return bool
     */
    private function baseResetQuery()
    {
        return $this->command(
            XML::create('base', ['type' => 'reset_query'])
        );
    }

    /**
     * Resets whole consultation
     * @return bool
     */
    private function baseResetBase()
    {
        return $this->command(
            XML::create('base', ['type' => 'reset_base'])
        );
    }

    /**
     * Goes back in the consultation
     * @return bool
     */
    private function baseGoBack()
    {
        return $this->command(
            XML::create('base', ['type' => 'go_back'])
        );
    }

    /**
     * Continues forward in the consultation
     * @return bool
     */
    private function baseGoAhead()
    {
        return $this->command(
            XML::create('base', ['type' => 'go_ahead'])
        );
    }

    ////////////////////////////////////////////////////////////////////////////

    /**
     * Creates the asynchronous process which will run NPSCore
     * @throws RuntimeException
     * @return 
     */
    private function createProcess()
    {
        $this->logger->info('Starting NPS process...');

        // Initialize the process
        self::$process = new Process($this->path);
        self::$processInput = new InputStream();
        self::$process->setInput(self::$processInput);
        //self::$process->setTimeout(10);
        
        // May throw a runtime exception when the process cannot be started
        // That should never happen - if needed, catch it in a different layer of the application
        self::$process->start();
    }

    /**
     * Terminates the NPS process
     */
    private function terminateProcess()
    {
        if(self::$process && self::$process->isRunning())
        {
            $this->logger->info('Terminating NPS process...');
            $this->cmdExit();

            if(self::forceProcessTermination())
            {
                $this->logger->info('NPS Process terminated');
            }
        }
    }

    /**
     * Executes a command on an NPS instance
     * @param XML $commandElement Element with the command
     * @param XML $output Element to which to store the output
     * @return bool
     */
    private function command(XML $commandElement, XML &$output = null) 
    {
        // Create the process if necessary
        if(!self::$process || !self::$process->isRunning())
        {
            $this->createProcess();
        }

        $this->logger->info((string)$commandElement);

        // Set the streams
        self::$processInput->write((string)$commandElement . PHP_EOL);

        // Hang while process gives us any output, may not take a lot of time 
        // Must react to the output's falling edge
        $output = '';
        $error = '';
        $risen = false;
        for($i = 0;;$i++)
        {
            // Give it some time to process it
            usleep(5E3); // 5ms

            $out = self::$process->getIncrementalOutput();
            $err = self::$process->getIncrementalErrorOutput();

            if(!$risen && ($out || $err))
            {
                // Rising edge detection
                $risen = true;
            }
            else if($risen && !trim($out) && !trim($err))
            {
                // Falling edge detection
                break;
            }

            $output .= $out;
            $error .= $err;

            // Exit command may cause the process to stop running
            if(!self::$process->isRunning())
            {
                $this->terminateProcess();

                if(Strings::contains('exit', $commandElement))
                {
                    return true;
                }
                
                $this->logger->critical('Process terminated: ' . $output);
                $this->logger->warning('Process stopped while waiting for its output - please try checking node literals for duplicity');
                
                return false;
                //throw new \LogicException('Process stopped while waiting for its output - please try checking node literals for duplicity');
            }

            // Too many iterations may not be permitted (simulates process timeout)
            if($i >  10 * 1000) // x seconds
            {
                $this->terminateProcess();
                $this->logger->critical('Process terminated: 10 second timeout reached - ' . ($output ?: 'no output'));
                return false;
            }
        }

        // Process internal error
        if($error)
        {
            $this->logger->critical($error);
            $this->pushMessage($error);
            throw new \LogicException($error);
        }   

        // Process output
        try 
        {
            $output = mb_convert_encoding($output, 'UTF-8');
            $output = XML::fromString($output);
            $output = $output->getChild('reply');
        }
        catch(\Exception $ex)
        {
            $this->logger->warning('NPS parse error (' . $ex->getMessage() . ') : ' . $output);
            $this->pushMessage($output);
            return false;
        }
        
        // Handle NPS errors
        $success = strtolower($output->getAttribute('type')) == 'ok';
        if(!$success)
        {
            $this->logger->warning($output);
            $this->pushMessage($output);
            return false;
        }

        // Successful execution of the command
        $this->logger->info($output);
        $this->pushMessage($output);
        
        return true;

    }

}

