<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
 *
 */

namespace oat\taoQtiTest\models\cat;

use GuzzleHttp\ClientInterface;
use oat\oatbox\service\ConfigurableService;
use oat\generis\model\OntologyAwareTrait;
use oat\libCat\CatEngine;
use oat\taoQtiTest\models\event\QtiContinueInteractionEvent;
use qtism\data\AssessmentTest;
use qtism\data\AssessmentSection;
use qtism\data\SectionPartCollection;
use qtism\data\AssessmentItemRef;
use qtism\data\storage\php\PhpDocument;
use qtism\runtime\tests\AssessmentTestSession;
use qtism\runtime\tests\RouteItem;
use oat\taoQtiTest\models\ExtendedStateService;
use oat\oatbox\event\EventManager;
use oat\taoQtiTest\models\event\InitializeAdaptiveSessionEvent;
use oat\taoQtiTest\models\CompilationDataService;

/**
 * Computerized Adaptive Testing Service
 *
 * This Service gives you access to a CatEngine object in addition
 * with relevant services to deal with CAT in TAO.
 *
 * @access public
 * @author Joel Bout, <joel@taotesting.com>
 * @package taoDelivery
 */
class CatService extends ConfigurableService
{
    use OntologyAwareTrait;

    const SERVICE_ID = 'taoQtiTest/CatService';

    const OPTION_ENGINE_ENDPOINTS = 'endpoints';

    const OPTION_ENGINE_URL = 'url';

    const OPTION_ENGINE_CLASS = 'class';

    const OPTION_ENGINE_ARGS = 'args';

    const OPTION_ENGINE_VERSION = 'version';

    const OPTION_ENGINE_CLIENT = 'client';

    const OPTION_INITIAL_CALL_TIMEOUT = 'initialCallTimeout';

    const OPTION_NEXT_ITEM_CALL_TIMEOUT = 'nextItemCallTimeout';

    const QTI_2X_ADAPTIVE_XML_NAMESPACE = 'http://www.taotesting.com/xsd/ais_v1p0p0';

    const CAT_ADAPTIVE_IDS_PROPERTY = 'http://www.tao.lu/Ontologies/TAOTest.rdf#QtiCatAdaptiveSections';

    const IS_CAT_ADAPTIVE = 'is-cat-adaptive';

    const IS_SHADOW_ITEM = 'is-shadow-item';

    private $engines = [];

    private $sectionMapCache = [];

    private $catSection = [];

    private $catSession = [];

    protected $isInitialCall = false;

    /**
     * Returns the Adaptive Engine
     *
     * Returns an CatEngine implementation object.
     * If it is the initial call, change endpoint name to differentiate it from nextItem call
     *
     * @param string $endpoint
     * @return CatEngine
     * @throws CatEngineNotFoundException
     */
    public function getEngine($endpoint)
    {
        if ($this->isInitialCall == true) {
            $endpointCached = $endpoint . '-init';
        } else {
            $endpointCached = $endpoint;
        }

        if (!isset($this->engines[$endpointCached])) {
            $endPoints = $this->getOption(self::OPTION_ENGINE_ENDPOINTS);

            if (!empty($endPoints[$endpoint])) {
                $engineOptions = $endPoints[$endpoint];

                $class = $engineOptions[self::OPTION_ENGINE_CLASS];
                $args = $engineOptions[self::OPTION_ENGINE_ARGS];
                $args = $this->alterTimeoutCallValue($args);
                $url = isset($engineOptions[self::OPTION_ENGINE_URL])
                    ? $engineOptions[self::OPTION_ENGINE_URL]
                    : $endpoint;
                array_unshift($args, $endpoint);

                try {
                    $this->engines[$endpointCached] = new $class($url, $this->getCatEngineVersion($args), $this->getCatEngineClient($args));
                } catch (\Exception $e) {
                    \common_Logger::e('Fail to connect to CAT endpoint : ' . $e->getMessage());
                    throw new CatEngineNotFoundException('CAT Engine for endpoint "' . $endpoint . '" is misconfigured.', $endpoint, 0, $e);
                }

            }
        }

        if (empty($this->engines[$endpointCached])) {
            // No configured endpoint found.
            throw new CatEngineNotFoundException("CAT Engine for endpoint '${endpoint}' is not configured.", $endpoint);
        }

        return $this->engines[$endpointCached];
    }

    /**
     * Get AssessmentItemRef by Identifier
     *
     * This method enables you to access to a pre-compiled version of a stand alone AssessmentItemRef, that can be run
     * with a stand alone AssessmentItemSession.
     *
     * @return \qtism\data\ExtendedAssessmentItemRef
     */
    public function getAssessmentItemRefByIdentifier(\tao_models_classes_service_StorageDirectory $privateCompilationDirectory, $identifier)
    {
        $compilationDataService = $this->getServiceLocator()->get(CompilationDataService::SERVICE_ID);
        $filename = "adaptive-assessment-item-ref-${identifier}";

        return $compilationDataService->readPhpCompilationData(
            $privateCompilationDirectory,
            "${filename}.php",
            "${filename}"
        );
    }

    /**
     * Get AssessmentItemRef by Identifiers
     *
     * This method enables you to access to a collection of pre-compiled versions of stand alone AssessmentItemRef objects, that can be run
     * with stand alone AssessmentItemSessions.
     *
     * @return array An array of AssessmentItemRef objects.
     */
    public function getAssessmentItemRefByIdentifiers(\tao_models_classes_service_StorageDirectory $privateCompilationDirectory, array $identifiers)
    {
        $assessmentItemRefs = [];

        foreach ($identifiers as $identifier) {
            $assessmentItemRefs[] = $this->getAssessmentItemRefByIdentifier($privateCompilationDirectory, $identifier);
        }

        return $assessmentItemRefs;
    }

    /**
     * Get AssessmentItemRefs corresponding to a given Adaptive Placeholder.
     *
     * This method will return an array of AssessmentItemRef objects corresponding to an Adaptive Placeholder.
     *
     * @return array
     */
    public function getAssessmentItemRefsByPlaceholder(\tao_models_classes_service_StorageDirectory $privateCompilationDirectory, AssessmentItemRef $placeholder)
    {
        $urlinfo = parse_url($placeholder->getHref());
        $adaptiveSectionId = ltrim($urlinfo['path'], '/');

        $compilationDataService = $this->getServiceLocator()->get(CompilationDataService::SERVICE_ID);
        $filename = "adaptive-assessment-section-${adaptiveSectionId}";

        $component = $compilationDataService->readPhpCompilationData(
            $privateCompilationDirectory,
            "${filename}.php",
            $filename
        );

        return $component->getComponentsByClassName('assessmentItemRef')->getArrayCopy();
    }

    /**
     * Get Information about a given Adaptive Section.
     *
     * This method returns Information about the "adaptivity" of a given QTI AssessmentSection.
     * The method returns an associative array containing the following information:
     *
     * * 'qtiSectionIdentifier' => The original QTI Identifier of the section.
     * * 'adaptiveSectionIdentifier' => The identifier of the adaptive section as known by the Adaptive Engine.
     * * 'adaptiveEngineRef' => The URL to the Adaptive Engine End Point to be used for that Adaptive Section.
     *
     * In case of the Assessment Section is not adaptive, the method returns false.
     *
     * @param \qtism\data\AssessmentTest $test A given AssessmentTest object.
     * @param \tao_models_classes_service_StorageDirectory $compilationDirectory The compilation directory where the test is compiled as a TAO Delivery.
     * @param string $qtiAssessmentSectionIdentifier The QTI identifier of the AssessmentSection you would like to get "adaptivity" information.
     * @return array|boolean Some "adaptivity" information or false in case of the given $qtiAssessmentSectionIdentifier does not correspond to an adaptive Assessment Section.
     */
    public function getAdaptiveAssessmentSectionInfo(AssessmentTest $test, \tao_models_classes_service_StorageDirectory $compilationDirectory, $basePath, $qtiAssessmentSectionIdentifier)
    {
        $info = CatUtils::getCatInfo($test);
        $adaptiveInfo = [
            'qtiSectionIdentifier' => $qtiAssessmentSectionIdentifier,
            'adaptiveSectionIdentifier' => false,
            'adaptiveEngineRef' => false
        ];

        if (isset($info[$qtiAssessmentSectionIdentifier])) {
            if (isset($info[$qtiAssessmentSectionIdentifier]['adaptiveEngineRef'])) {
                $adaptiveInfo['adaptiveEngineRef'] = $info[$qtiAssessmentSectionIdentifier]['adaptiveEngineRef'];
            }

            if (isset($info[$qtiAssessmentSectionIdentifier]['adaptiveSettingsRef'])) {
                 $adaptiveInfo['adaptiveSectionIdentifier'] = trim($compilationDirectory->read("./${basePath}/" . $info[$qtiAssessmentSectionIdentifier]['adaptiveSettingsRef']));
            }
        }

        return (!isset($info[$qtiAssessmentSectionIdentifier]['adaptiveEngineRef']) || !isset($info[$qtiAssessmentSectionIdentifier]['adaptiveSettingsRef'])) ? false : $adaptiveInfo;
    }

    public function getAdaptiveSectionMap(\tao_models_classes_service_StorageDirectory $privateCompilationDirectory)
    {
        $dirId = $privateCompilationDirectory->getId();

        if (!isset($this->sectionMapCache[$dirId])) {
            $file = $privateCompilationDirectory->getFile(\taoQtiTest_models_classes_QtiTestCompiler::ADAPTIVE_SECTION_MAP_FILENAME);
            $sectionMap = $file->exists() ? json_decode($file->read(), true) : [];
            $this->sectionMapCache[$dirId] = $sectionMap;
        }

        return $this->sectionMapCache[$dirId];
    }

    /**
     * Import XML data to QTI test RDF properties.
     *
     * This method will import the information found in the CAT specific information of adaptive sections
     * of a QTI test into the ontology for a given $test. This method is designed to be called at QTI Test Import time.
     *
     * @param \core_kernel_classes_Resource $testResource
     * @param \qtism\data\AssessmentTest $testDefinition
     * @param string $localTestPath The path to the related QTI Test Definition file (XML) during import.
     * @return bool
     * @throws \common_Exception In case of error.
     */
    public function importCatSectionIdsToRdfTest(\core_kernel_classes_Resource $testResource, AssessmentTest $testDefinition, $localTestPath)
    {
        $testUri = $testResource->getUri();
        $catProperties = [];
        $assessmentSections = $testDefinition->getComponentsByClassName('assessmentSection', true);
        $catInfo = CatUtils::getCatInfo($testDefinition);
        $testBasePath = pathinfo($localTestPath, PATHINFO_DIRNAME);

        /** @var AssessmentSection $assessmentSection */
        foreach ($assessmentSections as $assessmentSection) {
            $assessmentSectionIdentifier = $assessmentSection->getIdentifier();

            if (isset($catInfo[$assessmentSectionIdentifier])) {
                $settingsPath = "${testBasePath}/" . $catInfo[$assessmentSectionIdentifier]['adaptiveSettingsRef'];
                $settingsContent = trim(file_get_contents($settingsPath));
                $catProperties[$assessmentSectionIdentifier] = $settingsContent;

                $this->createAdaptiveSection($assessmentSection, $catInfo, $testBasePath);

                $this->validateAdaptiveAssessmentSection(
                    $assessmentSection->getSectionParts(),
                    $catInfo[$assessmentSectionIdentifier]['adaptiveEngineRef'],
                    $settingsContent
                );
            }
        }

        if (empty($catProperties)) {
            \common_Logger::t("No QTI CAT property value to store for test '${testUri}'.");
            return true;
        }

        if ($testResource->setPropertyValue($this->getProperty(self::CAT_ADAPTIVE_IDS_PROPERTY), json_encode($catProperties))) {
            return true;
        } else {
            throw new \common_Exception("Unable to store CAT property value to test '${testUri}'.");
        }
    }

    protected function createAdaptiveSection($assessmentSection, $catInfo, $testBasePath)
    {
        $assessmentSectionIdentifier = $assessmentSection->getIdentifier();
        $engine = $this->getEngine($catInfo[$assessmentSectionIdentifier]['adaptiveEngineRef']);
        $settingsPath = "${testBasePath}/" . $catInfo[$assessmentSectionIdentifier]['adaptiveSettingsRef'];

        $usagedataContent = null;
        if (isset($catInfo[$assessmentSectionIdentifier]['qtiUsagedataRef'])) {
            $usagedataPath = "${testBasePath}/" . $catInfo[$assessmentSectionIdentifier]['qtiUsagedataRef'];
            $usagedataContent = trim(file_get_contents($usagedataPath));
        }

        $metadataContent = null;
        if (isset($catInfo[$assessmentSectionIdentifier]['qtiMetadataRef'])) {
            $metadataPath = "${testBasePath}/" . $catInfo[$assessmentSectionIdentifier]['qtiMetadataRef'];
            $metadataContent = trim(file_get_contents($metadataPath));
        }

        $settingsContent = trim(file_get_contents($settingsPath));
        $adaptSection = $engine->setupSection($settingsContent, $usagedataContent, $metadataContent);
    }

    /**
     * Validation for adaptive section
     * @param SectionPartCollection $sectionsParts
     * @param string $ref
     * @param string $testAdminId
     * @throws AdaptiveSectionInjectionException
     */
    public function validateAdaptiveAssessmentSection(SectionPartCollection $sectionsParts, $ref, $testAdminId)
    {
        $engine = $this->getEngine($ref);
        $adaptSection = $engine->setupSection($testAdminId);
        //todo: remove this checking if tests/{getSectionId}/items will become a part of standard.
        if (method_exists($adaptSection, 'getItemReferences')) {
            $itemReferences = $adaptSection->getItemReferences();
            $dependencies = $sectionsParts->getKeys();

            if ($catDiff = array_diff($dependencies, $itemReferences)) {
                throw new AdaptiveSectionInjectionException('Missed some CAT service items: '. implode(', ', $catDiff), $catDiff);
            }

            if ($packageDiff = array_diff($dependencies, $itemReferences)) {
                throw new AdaptiveSectionInjectionException('Missed some package items: '. implode(', ', $packageDiff), $packageDiff);
            }
        }
    }

    /**
     * Is an AssessmentSection Adaptive?
     *
     * This method returns whether or not a given $section is adaptive.
     *
     * @param \qtism\data\AssessmentSection $section
     * @return boolean
     */
    public function isAssessmentSectionAdaptive(AssessmentSection $section)
    {
        $assessmentItemRefs = $section->getComponentsByClassName('assessmentItemRef');
        return count($assessmentItemRefs) === 1 && $this->isAdaptivePlaceholder($assessmentItemRefs[0]);
    }

    /**
     * Is an AssessmentItemRef an Adaptive Placeholder?
     *
     * This method returns whether or not a given $assessmentItemRef is a runtime adaptive placeholder.
     *
     * @param \qtism\data\AssessmentItemRef $assessmentItemRef
     * @return boolean
     */
    public function isAdaptivePlaceholder(AssessmentItemRef $assessmentItemRef)
    {
        return in_array(\taoQtiTest_models_classes_QtiTestCompiler::ADAPTIVE_PLACEHOLDER_CATEGORY, $assessmentItemRef->getCategories()->getArrayCopy());
    }

    /**
     * @deprecated set on SelectNextAdaptiveItemEvent
     */
    public function onQtiContinueInteraction($event)
    {
        if ($event instanceof QtiContinueInteractionEvent) {
            $context = $event->getContext();
            $isAdaptive = $context->isAdaptive();
            $isCat = false;

            if ($isAdaptive) {
                $isCat = true;
            }

            $itemIdentifier = $event->getContext()->getCurrentAssessmentItemRef()->getIdentifier();
            $hrefParts = explode('|', $event->getRunnerService()->getItemHref($context, $itemIdentifier));
            $event->getRunnerService()->storeTraceVariable($context, $hrefParts[0], self::IS_CAT_ADAPTIVE, $isCat);
        }
    }

    /**
     * Create the client and version, based on the entry $options.
     *
     * @param array $options
     * @throws \common_exception_InconsistentData
     */
    protected function getCatEngineClient(array $options = [])
    {
        if (!isset($options[self::OPTION_ENGINE_CLIENT])) {
            throw new \InvalidArgumentException('No API client provided. Cannot connect to endpoint.');
        }

        $client = $options[self::OPTION_ENGINE_CLIENT];
        if (is_array($client)) {
            $clientClass = isset($client['class']) ? $client['class'] : null;
            $clientOptions = isset($client['options']) ? $client['options'] : array();
            if (!is_a($clientClass, ClientInterface::class, true)) {
                throw new \InvalidArgumentException('Client has to implement ClientInterface interface.');
            }
            $client = new $clientClass($clientOptions);
        } elseif (is_object($client)) {
            if (!is_a($client, ClientInterface::class)) {
                throw new \InvalidArgumentException('Client has to implement ClientInterface interface.');
            }
        } else {
            throw new \InvalidArgumentException('Client is misconfigured.');
        }
        $this->propagate($client);
        return $client;
    }

    /**
     * @param array $options
     *
     * @return string
     */
    protected function getCatEngineVersion(array $options = [])
    {
        return isset($options[self::OPTION_ENGINE_VERSION]) ? $options[self::OPTION_ENGINE_VERSION] : '';
    }

    public function isAdaptive(AssessmentTestSession $testSession, AssessmentItemRef $currentAssessmentItemRef = null)
    {
        $currentAssessmentItemRef = (is_null($currentAssessmentItemRef)) ? $testSession->getCurrentAssessmentItemRef() : $currentAssessmentItemRef;

        if ($currentAssessmentItemRef) {
            return $this->isAdaptivePlaceholder($currentAssessmentItemRef);
        } else {
            return false;
        }
    }

    /**
     * If it is the initial call, reload cat section from $this->catSection cache
     *
     * @param AssessmentTestSession $testSession
     * @param \tao_models_classes_service_StorageDirectory $compilationDirectory
     * @param RouteItem|null $routeItem
     * @return mixed
     */
    public function getCatSection(AssessmentTestSession $testSession, \tao_models_classes_service_StorageDirectory $compilationDirectory, RouteItem $routeItem = null)
    {
        $routeItem = $routeItem ? $routeItem : $testSession->getRoute()->current();
        $sectionId = $routeItem->getAssessmentSection()->getIdentifier();

        if (!isset($this->catSection[$sectionId]) || $this->isInitialCall === true) {

            // No retrieval trial yet.
            $adaptiveSectionMap = $this->getAdaptiveSectionMap($compilationDirectory);

            if (isset($adaptiveSectionMap[$sectionId])) {
                $this->catSection[$sectionId] = $this->getCatEngine($testSession, $compilationDirectory, $routeItem)->restoreSection($adaptiveSectionMap[$sectionId]['section']);
            } else {
                $this->catSection[$sectionId] = false;
            }

        }

        return $this->catSection[$sectionId];
    }

    public function getCatEngine(AssessmentTestSession $testSession, \tao_models_classes_service_StorageDirectory $compilationDirectory, RouteItem $routeItem = null)
    {
        $adaptiveSectionMap = $this->getAdaptiveSectionMap($compilationDirectory);
        $routeItem = $routeItem ? $routeItem : $testSession->getRoute()->current();

        $sectionId = $routeItem->getAssessmentSection()->getIdentifier();
        $catEngine = false;

        if (isset($adaptiveSectionMap[$sectionId])) {
            $catEngine = $this->getEngine($adaptiveSectionMap[$sectionId]['endpoint']);
        }

        return $catEngine;
    }

    /**
     * @param AssessmentTestSession $testSession
     * @param \tao_models_classes_service_StorageDirectory $compilationDirectory
     * @param RouteItem|null $routeItem
     * @return array
     */
    public function getPreviouslySeenCatItemIds(AssessmentTestSession $testSession, \tao_models_classes_service_StorageDirectory $compilationDirectory, RouteItem $routeItem = null)
    {
        $result = [];

        if ($catSection = $this->getCatSection($testSession, $compilationDirectory, $routeItem)) {
            $items = $this->getServiceManager()->get(ExtendedStateService::SERVICE_ID)->getCatValue(
                $testSession->getSessionId(),
                $catSection->getSectionId(),
                'cat-seen-item-ids'
            );

            $result = !$items ? [] : json_decode($items);
        }

        return is_array($result) ? $result : [];
    }

    /**
     * @param AssessmentTestSession $testSession
     * @param \tao_models_classes_service_StorageDirectory $compilationDirectory
     * @param RouteItem|null $routeItem
     * @return array
     */
    public function getShadowTest(AssessmentTestSession $testSession, \tao_models_classes_service_StorageDirectory $compilationDirectory, RouteItem $routeItem = null)
    {
        $shadow = array_values(
            array_unique(
                array_merge(
                    $this->getPreviouslySeenCatItemIds($testSession, $compilationDirectory, $routeItem),
                    $this->getCatSession($testSession, $compilationDirectory, $routeItem)->getTestMap()
                )
            )
        );

        return $shadow;
    }

    /**
     * Get the current CAT Session Object.
     *
     * If it catSession from tao is not set, set the $this->isInitialCall to true
     *
     * @param AssessmentTestSession $testSession
     * @param \tao_models_classes_service_StorageDirectory $compilationDirectory
     * @param RouteItem|null $routeItem
     * @return \oat\libCat\CatSession|false
     */
    public function getCatSession(AssessmentTestSession $testSession, \tao_models_classes_service_StorageDirectory $compilationDirectory, RouteItem $routeItem = null)
    {
        if ($catSection = $this->getCatSection($testSession, $compilationDirectory, $routeItem)) {
            $catSectionId = $catSection->getSectionId();

            if (!isset($this->catSession[$catSectionId])) {
                // No retrieval trial yet in the current execution context.
                $this->catSession = false;

                $catSessionData = $this->getServiceManager()->get(ExtendedStateService::SERVICE_ID)->getCatValue(
                    $testSession->getSessionId(),
                    $catSection->getSectionId(),
                    'cat-session'
                );

                if ($catSessionData) {
                    // We already have something in persistence for the session, let's restore it.
                    $this->catSession[$catSectionId] = $catSection->restoreSession($catSessionData);
                    \common_Logger::d("CAT Session '" . $this->catSession[$catSectionId]->getTestTakerSessionId() . "' for CAT Section '${catSectionId}' restored.");
                } else {
                    // First time the session is required, let's initialize it.
                    $this->isInitialCall = true;
                    // Rebuild the catSection to be able to alter call options
                    $catSection = $this->getCatSection($testSession, $compilationDirectory, $routeItem);
                    $this->catSession[$catSectionId] = $catSection->initSession([], []);
                    $assessmentSection = $routeItem ? $routeItem->getAssessmentSection() : $testSession->getCurrentAssessmentSection();

                    $event = new InitializeAdaptiveSessionEvent(
                        $testSession,
                        $assessmentSection,
                        $this->catSession[$catSectionId]
                    );

                    $this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
                    $this->persistCatSession($this->catSession[$catSectionId], $testSession, $compilationDirectory, $routeItem);
                    \common_Logger::d("CAT Session '" . $this->catSession[$catSectionId]->getTestTakerSessionId() . "' for CAT Section '${catSectionId}' initialized and persisted.");
                }
            }

            return $this->catSession[$catSectionId];

        } else {
            return false;
        }
    }

    /**
     * Persist the CAT Session Data.
     *
     * Persist the current CAT Session Data in storage.
     *
     * @param string $catSession JSON encoded CAT Session data.
     * @param AssessmentTestSession $testSession
     * @param \tao_models_classes_service_StorageDirectory $compilationDirectory
     * @param RouteItem|null $routeItem
     */
    public function persistCatSession($catSession, AssessmentTestSession $testSession, \tao_models_classes_service_StorageDirectory $compilationDirectory, RouteItem $routeItem = null)
    {
        if ($catSection = $this->getCatSection($testSession, $compilationDirectory, $routeItem)) {
            $catSectionId = $catSection->getSectionId();
            $this->catSession[$catSectionId] = $catSession;

            $sessionId = $testSession->getSessionId();
            $this->getServiceManager()->get(ExtendedStateService::SERVICE_ID)->setCatValue(
                $sessionId,
                $catSectionId,
                'cat-session',
                json_encode($this->catSession[$catSectionId])
            );
        }
    }

    public function getCurrentCatItemId(AssessmentTestSession $testSession, \tao_models_classes_service_StorageDirectory $compilationDirectory, RouteItem $routeItem = null)
    {
        $sessionId = $testSession->getSessionId();

        $catItemId = $this->getServiceManager()->get(ExtendedStateService::SERVICE_ID)->getCatValue(
            $sessionId,
            $this->getCatSection($testSession, $compilationDirectory, $routeItem)->getSectionId(),
            'current-cat-item-id'
        );

        return $catItemId;
    }

    public function getCatAttempts(AssessmentTestSession $testSession, \tao_models_classes_service_StorageDirectory $compilationDirectory, $identifier, RouteItem $routeItem = null)
    {
        $catAttempts = $this->getServiceManager()->get(ExtendedStateService::SERVICE_ID)->getCatValue(
            $testSession->getSessionId(),
            $this->getCatSection($testSession, $compilationDirectory, $routeItem)->getSectionId(),
            'cat-attempts'
        );

        $catAttempts = ($catAttempts) ? $catAttempts : [];

        return (isset($catAttempts[$identifier])) ? $catAttempts[$identifier] : 0;
    }

    /**
     * Alter the timeout value for engine params
     *
     * Get the timeout value from options following if it is for initial or nextItem call
     * If it's not specified in the config, do not alter the $options
     *
     * @param array $options
     * @return array
     */
    protected function alterTimeoutCallValue(array $options)
    {
        $timeoutValue = null;
        if ($this->isInitialCall === true) {
            if ($this->hasOption(self::OPTION_INITIAL_CALL_TIMEOUT)) {
                $timeoutValue = $this->getOption(self::OPTION_INITIAL_CALL_TIMEOUT);
            }
        } else {
            if ($this->hasOption(self::OPTION_NEXT_ITEM_CALL_TIMEOUT)) {
                $timeoutValue = $this->getOption(self::OPTION_NEXT_ITEM_CALL_TIMEOUT);
            }
        }

        if (!is_null($timeoutValue)) {
            $options[self::OPTION_ENGINE_CLIENT]['options']['http_client_options']['timeout'] = $timeoutValue;
        }

        return $options;
    }

}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2017 (original work) Open Assessment Technologies SA ;
 */

namespace oat\taoQtiTest\models\runner\synchronisation\action;

use common_Exception;
use common_exception_Error;
use common_exception_InconsistentData;
use common_Logger;
use Exception;
use oat\oatbox\event\EventManager;
use oat\taoQtiTest\models\event\TraceVariableStored;
use oat\taoQtiTest\models\runner\synchronisation\TestRunnerAction;

/**
 * @package oat\taoQtiTest\models\runner\synchronisation\action
 */
class StoreTraceData extends TestRunnerAction
{
    /**
     * Process the storeTraceData action.
     *
     * Validate required fields.
     * Store trace data through runner service.
     * Trigger TraceVariableStored event.
     *
     * @return array
     * @throws common_Exception
     * @throws common_exception_Error
     * @throws common_exception_InconsistentData
     */
    public function process()
    {
        $this->validate();

        $itemRef = $this->hasRequestParameter('itemDefinition')
            ? $this->getItemRef($this->getRequestParameter('itemDefinition'))
            : null;

        $traceData = json_decode(html_entity_decode($this->getRequestParameter('traceData')), true);

        try {
            $serviceContext = $this->getServiceContext();
            $stored = 0;
            $size = count($traceData);

            foreach ($traceData as $variableIdentifier => $variableValue) {
                if ($this
                    ->getRunnerService()
                    ->storeTraceVariable($serviceContext, $itemRef, $variableIdentifier, $variableValue)
                ) {
                    $stored++;
                }
            }

            $response = [
                'success' => $stored == $size
            ];

            common_Logger::d('Stored "' . $stored . '/' . $size . '" trace variables');
            /** @var EventManager $eventManager */
            $eventManager = $this->getServiceLocator()->get(EventManager::SERVICE_ID);
            $event = new TraceVariableStored($serviceContext->getTestSession()->getSessionId(), $traceData);
            $eventManager->trigger($event);

        } catch (Exception $e) {
            $response = $this->getErrorResponse($e);
        }

        return $response;
    }

    /**
     * traceData field is required.
     *
     * @return array
     */
    protected function getRequiredFields()
    {
        return array_merge(parent::getRequiredFields(), ['traceData']);
    }
}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2016 (original work) Open Assessment Technologies SA ;
 */
/**
 * @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
 */

namespace oat\taoQtiTest\models\runner\navigation;

use oat\taoQtiTest\models\runner\RunnerServiceContext;

/**
 * Class QtiRunnerSkipItem
 * @package oat\taoQtiTest\models\runner\navigation
 */
class QtiRunnerNavigationSkipItem implements RunnerNavigation
{
    /**
     * Do the move
     * @param RunnerServiceContext $context
     * @param mixed $ref
     * @return boolean
     * @throws \common_Exception
     */
    public function move(RunnerServiceContext $context, $ref)
    {
        $session = $context->getTestSession();
        $session->skip();
        $session->moveNext();
        return true;
    }

}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2016-2017 (original work) Open Assessment Technologies SA ;
 */

namespace oat\taoQtiTest\models\runner\time;

use oat\tao\model\state\StateStorage;
use oat\taoQtiTest\models\runner\StorageManager;
use oat\taoTests\models\runner\time\TimeStorage;

/**
 * Class QtiTimeStorage
 * @package oat\taoQtiTest\models\runner\time
 * @author Jean-Sébastien Conan <jean-sebastien.conan@taotesting.com>
 */
class QtiTimeStorage implements TimeStorage, QtiTimeStorageFormatAware
{
    use QtiTimeStorageFormatAwareTrait;

    /**
     * Prefix used to identify the data slot in the storage
     */
    const STORAGE_PREFIX = 'timer_';

    /**
     * Local cache used to maintain data in memory while the request is running
     * @var array
     */
    protected $cache = null;

    /**
     * The assessment test session identifier
     * @var string
     */
    protected $testSessionId;

    /**
     * The assessment test user identifier
     * @var string
     */
    protected $userId;

    /**
     * @var StateStorage
     */
    protected $storageService;

    /**
     * QtiTimeStorage constructor.
     * @param string $testSessionId
     * @param string $userId
     */
    public function __construct($testSessionId, $userId)
    {
        $this->testSessionId = $testSessionId;
        $this->userId = $userId;
    }

    /**
     * Gets the key identifying the storage for the provided user
     * @return string
     */
    protected function getStorageKey()
    {
        return self::getStorageKeyFromTestSessionId($this->testSessionId);
    }

    /**
     * Storage Key from Test Session Id
     *
     * Returns the Storage Key corresponding to a fiven $testSessionId
     *
     * @param string $testSessionId
     * @return string
     */
    public static function getStorageKeyFromTestSessionId($testSessionId)
    {
        return self::STORAGE_PREFIX . $testSessionId;
    }

    /**
     * Gets the user key to access the storage
     * @return string
     */
    protected function getUserKey()
    {
        return $this->userId;
    }

    /**
     * Gets the StateStorage service
     * @return StateStorage
     */
    public function getStorageService()
    {
        if (!$this->storageService) {
            $this->storageService = \tao_models_classes_service_StateStorage::singleton();
        }
        return $this->storageService;
    }

    /**
     * Sets the StateStorage service
     * @param StateStorage $storageService
     */
    public function setStorageService($storageService)
    {
        $this->storageService = $storageService;
    }

    /**
     * Stores the timer data
     * @param array $data
     * @return TimeStorage
     */
    public function store($data)
    {
        $this->cache[$this->testSessionId] = &$data;
        $encodedData = $this->getStorageFormat()->encode($data);

        $this->getStorageService()->set($this->getUserKey(), $this->getStorageKey(), $encodedData);
        \common_Logger::d(sprintf('QtiTimer: Stored %d bytes into state storage', strlen($encodedData)));

        return $this;
    }

    /**
     * Loads the timer data from the storage
     * @return array
     */
    public function load()
    {
        if (!isset($this->cache[$this->testSessionId])) {
            $encodedData = $this->getStorageService()->get($this->getUserKey(), $this->getStorageKey());
            \common_Logger::d(sprintf('QtiTimer: Loaded %d bytes from state storage', strlen($encodedData)));

            $this->cache[$this->testSessionId] = $this->getStorageFormat()->decode($encodedData);
        }

        return $this->cache[$this->testSessionId];
    }

    /**
     * @inheritdoc
     */
    public function delete()
    {
        $storage = $this->getStorageService();
        $result = $this->getStorageService()->del($this->getUserKey(), $this->getStorageKey());
        if ($storage instanceof StorageManager) {
            $result = $storage->persist($this->getUserKey());
        }
        return $result;
    }
}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2017 (original work) Open Assessment Technologies SA ;
 */

namespace oat\taoQtiTest\models\runner\synchronisation;

use oat\oatbox\service\ConfigurableService;
use oat\taoQtiTest\models\runner\QtiRunnerService;
use oat\taoQtiTest\models\runner\QtiRunnerServiceContext;

class SynchronisationService extends ConfigurableService
{

    const SERVICE_ID = 'taoQtiTest/synchronisationService';
    const ACTIONS_OPTION = 'actions';

    /**
     * Typical amount of time added on TimePoints to avoid timestamp collisions.
     * This value will be used to adjust intervals between moves in the synced time line.
     */
    const TIMEPOINT_INTERVAL = .001;

    /**
     * Wrap the process to appropriate action and aggregate results
     *
     * @param $data
     * @param $serviceContext QtiRunnerServiceContext
     * @return array
     * @throws
     */
    public function process($data, $serviceContext)
    {
        if (empty($data)) {
            throw new \common_exception_InconsistentData('No action to check. Processing action requires data.');
        }

        // first, extract the actions and build usable instances
        // also compute the total duration to synchronise
        $actions = [];
        $duration = 0;
        foreach ($data as $entry) {
            $action = $this->resolve($entry);
            $actions[] = $action;

            if ($action->hasRequestParameter('itemDuration')) {
                $duration += $action->getRequestParameter('itemDuration') + self::TIMEPOINT_INTERVAL;
            }
        }

        // determine the start timestamp of the actions:
        // - check if the total duration of actions to sync is comprised within
        //   the elapsed time since the last TimePoint.
        // - otherwise compute the start timestamp from now minus the duration
        //   (caution! this could introduce inconsistency in the TimeLine as the ranges could be interlaced)
        $now = microtime(true);
        $last = $serviceContext->getTestSession()->getTimer()->getLastRegisteredTimestamp();
        $elapsed = $now - $last;
        if ($duration > $elapsed) {
            \common_Logger::t('Ignoring the last timestamp to take into account the actual duration to sync. Could introduce TimeLine inconsistency!');
            $last = $now - $duration;
        }

        // ensure the actions are in chronological order
        usort($actions, function($a, $b) {
           return $a->getTimestamp() - $b->getTimestamp();
        });

        $response = [];

        /** @var TestRunnerAction $action */
        foreach( $actions as $action) {
            try {
                $serviceContext->setSyncingMode($action->getRequestParameter('offline'));
                if ($action->hasRequestParameter('itemDuration') && $serviceContext->isSyncingMode()) {
                    $last += $action->getRequestParameter('itemDuration') + self::TIMEPOINT_INTERVAL;
                    $action->setTime($last);
                } else {
                    $action->setTime($now);
                }

                $action->setServiceContext($serviceContext);
                if ($serviceContext instanceof QtiRunnerServiceContext) {
                }
                $responseAction = $action->process();
            } catch (\common_Exception $e) {
                $responseAction = ['error' => $e->getMessage()];
                $responseAction['success'] = false;
            }

            $responseAction['name'] = $action->getName();
            $responseAction['timestamp'] = $action->getTimeStamp();
            $responseAction['requestParameters'] = $action->getRequestParameters();

            $response[] = $responseAction;

            if ($responseAction['success'] === false) {
                break;
            }
        }

        $this->getRunnerService()->persist($serviceContext);

        return $response;
    }

    /**
     * Get available actions from config
     *
     * @return array
     */
    public function getAvailableActions()
    {
        return is_array($this->getOption(self::ACTIONS_OPTION))
            ? $this->getOption(self::ACTIONS_OPTION)
            : [];
    }

    /**
     * Set available actions to config
     *
     * @param array $actions
     */
    public function setAvailableActions(array $actions = [])
    {
        $this->setOption(self::ACTIONS_OPTION, $actions);
    }

    /**
     * Resolve a runner action to synchronize
     *
     * @param $data
     * @return TestRunnerAction
     * @throws \ResolverException
     * @throws \common_exception_InconsistentData
     */
    protected function resolve($data)
    {
        if (!isset($data['action']) || !isset($data['timestamp']) || !isset($data['parameters']) || !is_array($data['parameters'])) {
            throw new \common_exception_InconsistentData(
                'Action parameters have to contain "action", "timestamp" and "parameters" fields.');
        }

        $availableActions = $this->getAvailableActions();
        $actionName = $data['action'];

        $actionClass = null;

        // Search by key (e.q. key)
        if (isset($availableActions[$actionName])) {
            $actionClass = $availableActions[$actionName];
        }

        if (is_null($actionClass) || !is_a($actionClass, TestRunnerAction::class, true)) {
            throw new \ResolverException('Action name "' . $actionName . '" could not be resolved.');
        }

        return $this->getServiceManager()->propagate(new $actionClass($actionName, $data['timestamp'], $data['parameters']));
    }

    /**
     * @return QtiRunnerService
     */
    protected function getRunnerService()
    {
        return $this->getServiceLocator()->get(QtiRunnerService::SERVICE_ID);
    }
}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2017 (original work) Open Assessment Technologies SA ;
 */

namespace oat\taoQtiTest\models\runner\synchronisation\action;

use common_exception_Unauthorized;
use common_Logger;
use oat\taoQtiTest\models\runner\synchronisation\TestRunnerAction;
use stdClass;

/**
 * @package oat\taoQtiTest\models\runner\synchronisation\action
 */
class NextItemData extends TestRunnerAction
{
    /**
     * Get item data by identifier
     *
     * Validate required fields.
     * Get item data by given identifier
     *
     * @return array
     * @throws
     */
    public function process()
    {
        $this->validate();

        $itemIdentifier = $this->hasRequestParameter('itemDefinition')
            ? $this->getRequestParameter('itemDefinition')
            : null;

        if (!is_array($itemIdentifier)) {
            $itemIdentifier = [$itemIdentifier];
        }

        try {
            if (!$this->getRunnerService()->getTestConfig()->getConfigValue('itemCaching.enabled')) {
                common_Logger::w('Attempt to disclose the next items without the configuration');
                throw new common_exception_Unauthorized();
            }

            $response = [];
            foreach ($itemIdentifier as $itemId) {
                //load item data
                $response['items'][] = $this->getItemData($itemId);
            }

            if (isset($response['items'])) {
                $response['success'] = true;
            }
        } catch (\Exception $e) {
            $response = $this->getErrorResponse($e);
        }

        return $response;
    }

    /**
     * Create the item definition response for a given item
     * @param string $itemIdentifier the item id
     * @return array the item data
     * @throws \common_Exception
     */
    protected function getItemData($itemIdentifier)
    {
        $serviceContext = $this->getServiceContext();
        $itemRef = $this->getRunnerService()->getItemHref($serviceContext, $itemIdentifier);
        $itemData = $this->getRunnerService()->getItemData($serviceContext, $itemRef);
        $baseUrl = $this->getRunnerService()->getItemPublicUrl($serviceContext, $itemRef);

        $itemState = $this->getRunnerService()->getItemState($serviceContext, $itemIdentifier);
        if ($itemState === null || !count($itemState)) {
            $itemState = new stdClass();
        }

        return [
            'baseUrl' => $baseUrl,
            'itemData' => $itemData,
            'itemState' => $itemState,
            'itemIdentifier' => $itemIdentifier,
        ];
    }

    /**
     * `itemDefinition` field is required.
     *
     * @return array
     */
    protected function getRequiredFields()
    {
        return array_merge(parent::getRequiredFields(), ['itemDefinition']);
    }
}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2017 (original work) Open Assessment Technologies SA ;
 *
 */
/**
 * @author Jean-Sébastien Conan <jean-sebastien@taotesting.com>
 */

namespace oat\taoQtiTest\models\runner;

use oat\oatbox\service\ConfigurableService;
use qtism\runtime\tests\AssessmentTestSession;
use qtism\runtime\tests\AssessmentTestSessionState;

/**
 * Class QtiRunnerMessageService
 *
 * Defines a service that will provide messages for the test runner
 *
 * @package oat\taoQtiTest\models
 */
class QtiRunnerMessageService extends ConfigurableService implements RunnerMessageService
{
    const SERVICE_ID = 'taoQtiTest/QtiRunnerMessageService';

    const PAUSED_STATE_MESSAGE = 'The assessment has been suspended. To resume your assessment, please relaunch it.';
    const TERMINATED_STATE_MESSAGE = 'The assessment has been terminated. You cannot interact with it anymore.';
    const INITIAL_STATE_MESSAGE = 'The assessment has been created but is not already running.';
    const RUNNING_STATE_MESSAGE = 'The assessment is still running.';

    /**
     * Gets a message related to the state of the assessment test session
     * @param mixed $testSession
     * @return string
     * @throws \common_exception_InvalidArgumentType
     */
    public function getStateMessage($testSession)
    {
        if ($testSession instanceof AssessmentTestSession) {
            switch ($testSession->getState()) {
                case AssessmentTestSessionState::SUSPENDED:
                    return $this->getPausedStateMessage($testSession);

                case AssessmentTestSessionState::CLOSED:
                    return $this->getTerminatedStateMessage($testSession);

                case AssessmentTestSessionState::INITIAL:
                    return $this->getInitialStateMessage($testSession);

                default:
                    return $this->getRunningStateMessages($testSession);
            }
        } else {
            throw new \common_exception_InvalidArgumentType(
                'QtiRunnerMessageService',
                'getStateMessage',
                0,
                'qtism\runtime\tests\AssessmentTestSession',
                $testSession
            );
        }
    }

    /**
     * Gets a message about the paused status of the assessment
     * @param AssessmentTestSession $testSession
     * @return string
     */
    protected function getPausedStateMessage(AssessmentTestSession $testSession)
    {
        return static::PAUSED_STATE_MESSAGE;
    }

    /**
     * Gets a message about the terminated status of the assessment
     * @param AssessmentTestSession $testSession
     * @return string
     */
    protected function getTerminatedStateMessage(AssessmentTestSession $testSession)
    {
        return static::TERMINATED_STATE_MESSAGE;
    }

    /**
     * Gets a message about the initial status of the assessment
     * @param AssessmentTestSession $testSession
     * @return string
     */
    protected function getInitialStateMessage(AssessmentTestSession $testSession)
    {
        return static::INITIAL_STATE_MESSAGE;
    }

    /**
     * Gets a message about the running status of the assessment
     * @param AssessmentTestSession $testSession
     * @return string
     */
    protected function getRunningStateMessages(AssessmentTestSession $testSession)
    {
        return static::RUNNING_STATE_MESSAGE;
    }
}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2019 (original work) Open Assessment Technologies SA ;
 */
/**
 * @author Péter Halász <peter@taotesting.com>
 */

namespace oat\taoQtiTest\models\runner;

use oat\oatbox\service\ConfigurableService;
use oat\taoQtiTest\models\runner\map\RunnerMap;

/**
 * Service class for the offline version of Qti Test Runner
 */
class OfflineQtiRunnerService extends ConfigurableService
{
    const SERVICE_ID = 'taoQtiTest/OfflineQtiRunnerService';

    /**
     * Returns an array of items, containing also confident data, like branching and response processing rules
     *
     * @param RunnerServiceContext $serviceContext
     * @return array
     * @throws \common_Exception
     * @throws \common_exception_Error
     * @throws \common_exception_InconsistentData
     * @throws \common_exception_InvalidArgumentType
     */
    public function getItems(RunnerServiceContext $serviceContext)
    {
        $this->getRunnerService()->assertQtiRunnerServiceContext($serviceContext);

        $runnerService = $this->getRunnerService();
        $testMap = $runnerService->getTestMap($serviceContext);
        $items = [];

        foreach ($this->getItemIdentifiersFromTestMap($testMap) as $itemIdentifier) {
            $itemRef = $runnerService->getItemHref($serviceContext, $itemIdentifier);

            /** @var QtiRunnerServiceContext $serviceContext */
            $items[$itemIdentifier] = [
                'baseUrl' => $runnerService->getItemPublicUrl($serviceContext, $itemRef),
                'itemData' => $this->getItemData($serviceContext, $itemRef),
                'itemState' => $runnerService->getItemState($serviceContext, $itemIdentifier),
                'itemIdentifier' => $itemIdentifier,
                'portableElements' => $runnerService->getItemPortableElements($serviceContext, $itemRef),
            ];
        }

        return $items;
    }

    /**
     * Returns the itemData, extending with the variable elements
     *
     * @param RunnerServiceContext $context
     * @param string $itemRef
     * @return array
     * @throws \common_exception_InvalidArgumentType
     * @throws \common_Exception
     */
    private function getItemData(RunnerServiceContext $context, $itemRef)
    {
        $this->getRunnerService()->assertQtiRunnerServiceContext($context);

        $itemData = $this->getRunnerService()->getItemData($context, $itemRef);
        $itemDataVariable = $this->getRunnerService()->getItemVariableElementsData($context, $itemRef);
        $responses = $itemData['data']['responses'];

        foreach (array_keys($responses) as $responseId) {
            if (array_key_exists($responseId, $itemDataVariable)) {
                $itemData['data']['responses'][$responseId] = array_merge(...[
                    $responses[$responseId],
                    $itemDataVariable[$responseId],
                ]);
            }
        }

        return $itemData;
    }

    /**
     * Returns the item identifiers
     *
     * @param array $testMap
     * @return array
     */
    private function getItemIdentifiersFromTestMap($testMap)
    {
        return $this->getSubIdentifiersRecursively($testMap, [
            RunnerMap::MAP_ATTRIBUTE_PARTS,
            RunnerMap::MAP_ATTRIBUTE_SECTIONS,
            RunnerMap::MAP_ATTRIBUTE_ITEMS,
        ]);
    }

    /**
     * Calls itself recursively to return identifiers from nested arrays
     *
     * @param $array
     * @param $identifiers
     * @return array
     */
    private function getSubIdentifiersRecursively($array, $identifiers)
    {
        $identifier = array_shift($identifiers);
        if (count($identifiers) > 0) {
            $result = [];

            foreach ($array[$identifier] as $key => $value) {
                $result[] = $this->getSubIdentifiersRecursively(
                    $array[$identifier][$key],
                    $identifiers
                );
            }

            return array_merge(...$result);
        }

        return array_keys($array[$identifier]);
    }

    /**
     * @return ConfigurableService|QtiRunnerService
     */
    private function getRunnerService()
    {
        return $this->getServiceLocator()->get(QtiRunnerService::SERVICE_ID);
    }
}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2015-2017 (original work) Open Assessment Technologies SA;
 */

namespace oat\taoQtiTest\models\runner;

use oat\oatbox\service\ServiceManagerAwareInterface;
use oat\oatbox\service\ServiceManagerAwareTrait;
use oat\taoDelivery\model\execution\Delete\DeliveryExecutionDelete;
use oat\taoDelivery\model\execution\Delete\DeliveryExecutionDeleteRequest;

/**
 * Manage the flagged items
 */
class ExtendedState implements ServiceManagerAwareInterface, DeliveryExecutionDelete
{
    use ServiceManagerAwareTrait;

    const STORAGE_PREFIX = 'extra_';
    const VAR_REVIEW = 'review';
    const VAR_STORE_ID = 'client_store_id';
    const VAR_EVENTS_QUEUE = 'events_queue';
    const VAR_CAT = 'cat';
    const VAR_HREF_INDEX = 'item_href_index';

    /**
     * @var string
     */
    protected $testSessionId;

    /**
     * @var string
     */
    protected $userId;

    /**
     * @var array
     */
    protected $state = [];

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

    /**
     * ExtendedState constructor.
     * @param string $testSessionId
     * @param string $userId
     */
    public function __construct($testSessionId = '', $userId = '')
    {
        $this->setTestSessionId($testSessionId);
        $this->setUserId($userId);
    }

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

    /**
     * @param string $testSessionId
     * @return ExtendedState
     */
    public function setTestSessionId($testSessionId)
    {
        $this->testSessionId = $testSessionId;
        return $this;
    }

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

    /**
     * @param string $userId
     * @return ExtendedState
     */
    public function setUserId($userId)
    {
        $this->userId = $userId;
        return $this;
    }

    /**
     * Gets the StorageManager service
     * @return StorageManager
     */
    public function getStorage()
    {
        if (!$this->storage) {
            $this->storage = $this->getServiceLocator()->get(StorageManager::SERVICE_ID);
        }
        return $this->storage;
    }

    /**
     * Sets the StorageManager service
     * @param StorageManager $storage
     * @return ExtendedState
     */
    public function setStorage($storage)
    {
        $this->storage = $storage;
        return $this;
    }

    /**
     * Gets the storage key based on the provided Test Session Id.
     * @param string $testSessionId
     * @return string
     */
    public static function getStorageKeyFromTestSessionId($testSessionId)
    {
        return self::STORAGE_PREFIX . $testSessionId;
    }

    /**
     * Gets the storage key based on the nested Test Session Id.
     * @return string
     */
    public function getStorageKey()
    {
        return self::getStorageKeyFromTestSessionId($this->testSessionId);
    }

    /**
     * Loads the extended state from the storage
     * @return bool
     */
    public function load()
    {
        $storage = $this->getStorage();
        if ($storage) {
            $data = $storage->get($this->userId, $this->getStorageKey());
            if ($data) {
                $this->state = json_decode($data, true);
            } else {
                $this->state = [];
            }
            $success = is_array($this->state);
        } else {
            $success = false;
        }
        return $success;
    }

    /**
     * Saves the extended state into the storage
     * @return bool
     */
    public function save()
    {
        $storage = $this->getStorage();
        if ($storage) {
            $success = $storage->set($this->userId, $this->getStorageKey(), json_encode($this->state));
        } else {
            $success = false;
        }
        return $success;
    }

    /**
     * Set the marked for review state of an item
     * @param string $itemRef
     * @param bool $flag
     * @return ExtendedState
     */
    public function setItemFlag($itemRef, $flag)
    {
        $this->state[self::VAR_REVIEW][$itemRef] = $flag;
        return $this;
    }

    /**
     * Gets the marked for review state of an item
     * @param string $itemRef
     * @return bool
     */
    public function getItemFlag($itemRef)
    {
        return isset($this->state[self::VAR_REVIEW]) && isset($this->state[self::VAR_REVIEW][$itemRef])
            ? $this->state[self::VAR_REVIEW][$itemRef]
            : false;
    }

    /**
     * Sets the name of the client store used for the timer
     * @param string $storeId
     * @return ExtendedState
     */
    public function setStoreId($storeId)
    {
        $this->state[self::VAR_STORE_ID] = $storeId;
        return $this;
    }

    /**
     * Gets the name of the client store used for the timer
     * @return bool
     */
    public function getStoreId()
    {
        return isset($this->state[self::VAR_STORE_ID]) ? $this->state[self::VAR_STORE_ID] : false;
    }

    /**
     * Add an event on top of the queue
     * @param string $eventName
     * @param mixed $data
     * @return string
     * @throws \common_Exception
     */
    public function addEvent($eventName, $data = null)
    {
        $eventId = uniqid('event', true);
        $this->state[self::VAR_EVENTS_QUEUE][$eventId] = [
            'id' => $eventId,
            'timestamp' => microtime(true),
            'user' => \common_session_SessionManager::getSession()->getUserUri(),
            'type' => $eventName,
            'data' => $data,
        ];
        return $eventId;
    }

    /**
     * Gets all events from the queue
     * @return array
     */
    public function getEvents()
    {
        if (isset($this->state[self::VAR_EVENTS_QUEUE])) {
            $events = $this->state[self::VAR_EVENTS_QUEUE];
        } else {
            $events = [];
        }
        return $events;
    }

    /**
     * Removes particular events from the queue
     * @param array $ids
     * @return bool
     */
    public function removeEvents($ids = [])
    {
        $removed = false;
        if (isset($this->state[self::VAR_EVENTS_QUEUE])) {
            foreach ($ids as $id) {
                if (isset($this->state[self::VAR_EVENTS_QUEUE][$id])) {
                    unset($this->state[self::VAR_EVENTS_QUEUE][$id]);
                    $removed = true;
                }
            }
        }
        return $removed;
    }

    /**
     * Removes all events from the queue
     * @return ExtendedState
     */
    public function clearEvents()
    {
        $this->state[self::VAR_EVENTS_QUEUE] = [];
        return $this;
    }

    /**
     * Stores the table that maps the items identifiers to item reference
     * Fallback index in case of the delivery was compiled without the index of item href
     * @param array $table
     * @return ExtendedState
     */
    public function setItemHrefIndex($table)
    {
        $this->state[self::VAR_HREF_INDEX] = $table;
        return $this;
    }

    /**
     * Loads the table that maps the items identifiers to item reference
     * Fallback index in case of the delivery was compiled without the index of item href
     * @return array
     */
    public function getItemHrefIndex()
    {
        if (isset($this->state[self::VAR_HREF_INDEX])) {
            $table = $this->state[self::VAR_HREF_INDEX];
        } else {
            $table = [];
        }
        return $table;
    }

    /**
     * Sets a CAT value in the Extended State.
     * @param string $assessmentSectionId
     * @param string $key
     * @param string $value
     * @return ExtendedState
     */
    public function setCatValue($assessmentSectionId, $key, $value)
    {
        $this->state[self::VAR_CAT][$assessmentSectionId][$key] = $value;
        return $this;
    }

    /**
     * Gets a CAT value from the Extended State.
     * @param string $assessmentSectionId
     * @param string $key
     * @return string
     */
    public function getCatValue($assessmentSectionId, $key)
    {
        return (isset($this->state[self::VAR_CAT]) && isset($this->state[self::VAR_CAT][$assessmentSectionId]) && isset($this->state[self::VAR_CAT][$assessmentSectionId][$key])) ? $this->state[self::VAR_CAT][$assessmentSectionId][$key] : null;
    }

    /**
     * Removes a CAT value from the ExtendedState.
     * @param string $assessmentSectionId
     * @param string $key
     * @return ExtendedState
     */
    public function removeCatValue($assessmentSectionId, $key)
    {
        if (isset($this->state[self::VAR_CAT]) && isset($this->state[self::VAR_CAT][$assessmentSectionId]) && isset($this->state[self::VAR_CAT][$assessmentSectionId][$key])) {
            unset($this->state[self::VAR_CAT][$assessmentSectionId][$key]);
        }
        return $this;
    }

    /**
     * @inheritdoc
     */
    public function deleteDeliveryExecutionData(DeliveryExecutionDeleteRequest $request)
    {
        $storage = $this->getStorage();
        if ($storage) {
            return $storage->del($this->userId, $this->getStorageKey());
        }

        return false;
    }
}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2015-2017 (original work) Open Assessment Technologies SA;
 */

namespace oat\taoQtiTest\models;

use oat\oatbox\service\ConfigurableService;
use oat\tao\model\state\StateStorage;
use oat\taoDelivery\model\execution\ServiceProxy;
use oat\taoDelivery\model\execution\Delete\DeliveryExecutionDelete;
use oat\taoDelivery\model\execution\Delete\DeliveryExecutionDeleteRequest;
use oat\taoQtiTest\models\runner\ExtendedState;
use oat\taoQtiTest\models\runner\StorageManager;

/**
 * Manage the flagged items
 */
class ExtendedStateService extends ConfigurableService implements DeliveryExecutionDelete
{
    const SERVICE_ID = 'taoQtiTest/ExtendedStateService';

    protected $cache = [];
    protected $deliveryExecutions = [];

    /**
     * @var StateStorage
     */
    protected $storageService;

    /**
     * Gets the StateStorage service
     * @return StorageManager
     */
    public function getStorageService()
    {
        if (!$this->storageService) {
            $this->storageService = $this->getServiceLocator()->get(StorageManager::SERVICE_ID);
        }
        return $this->storageService;
    }

    /**
     * Sets the StateStorage service
     * @param StateStorage $storageService
     */
    public function setStorageService($storageService)
    {
        $this->storageService = $storageService;
    }

    /**
     * @param string $testSessionId
     * @return string
     * @throws \common_exception_Error
     */
    protected function getSessionUserUri($testSessionId)
    {
        if (!isset($this->deliveryExecutions[$testSessionId])) {
            $this->deliveryExecutions[$testSessionId] = ServiceProxy::singleton()->getDeliveryExecution($testSessionId);
        }
        if ($this->deliveryExecutions[$testSessionId]) {
            return $this->deliveryExecutions[$testSessionId]->getUserIdentifier();
        }
        return \common_session_SessionManager::getSession()->getUserUri();
    }

    /**
     * @param $testSessionId
     * @return ExtendedState
     * @throws \common_exception_Error
     */
    public function getExtendedState($testSessionId)
    {
        if (!isset($this->cache[$testSessionId])) {
            $extendedState = new ExtendedState($testSessionId, $this->getSessionUserUri($testSessionId));
            $this->getServiceManager()->propagate($extendedState);
            $extendedState->setStorage($this->getStorageService());
            $extendedState->load();
            $this->cache[$testSessionId] = $extendedState;
        }
        return $this->cache[$testSessionId];
    }

    /**
     * Persists the extended state
     * @param string $testSessionId
     * @throws \common_exception_Error
     */
    public function persist($testSessionId)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        $extendedState->save();
        $this->getStorageService()->persist($extendedState->getUserId(), $extendedState->getStorageKey());
    }

    /**
     * Set the marked for review state of an item
     * @param string $testSessionId
     * @param string $itemRef
     * @param boolean $flag
     * @throws \common_Exception
     */
    public function setItemFlag($testSessionId, $itemRef, $flag)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        $extendedState->setItemFlag($itemRef, $flag);
        $extendedState->save();
    }

    /**
     * Gets the marked for review state of an item
     * @param string $testSessionId
     * @param string $itemRef
     * @return bool
     * @throws \common_Exception
     */
    public function getItemFlag($testSessionId, $itemRef)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        return $extendedState->getItemFlag($itemRef);
    }

    /**
     * Sets the name of the client store used for the timer
     * @param string $testSessionId
     * @param string $storeId
     * @throws \common_Exception
     */
    public function setStoreId($testSessionId, $storeId)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        $extendedState->setStoreId($storeId);
        $extendedState->save();
    }

    /**
     * Gets the name of the client store used for the timer
     * @param string $testSessionId
     * @return bool
     * @throws \common_Exception
     */
    public function getStoreId($testSessionId)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        return $extendedState->getStoreId();
    }

    /**
     * Add an event on top of the queue
     * @param string $testSessionId
     * @param string $eventName
     * @param mixed $data
     * @return string
     * @throws \common_Exception
     */
    public function addEvent($testSessionId, $eventName, $data = null)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        $eventId = $extendedState->addEvent($eventName, $data);
        $extendedState->save();
        return $eventId;
    }

    /**
     * Gets all events from the queue
     * @param $testSessionId
     * @return array|mixed
     * @throws \common_Exception
     */
    public function getEvents($testSessionId)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        return $extendedState->getEvents();
    }

    /**
     * Removes particular events from the queue
     * @param $testSessionId
     * @param array $ids
     * @throws \common_Exception
     */
    public function removeEvents($testSessionId, $ids = [])
    {
        $extendedState = $this->getExtendedState($testSessionId);
        $extendedState->removeEvents($ids);
        $extendedState->save();
    }

    /**
     * Removes all events from the queue
     * @param $testSessionId
     * @throws \common_Exception
     */
    public function clearEvents($testSessionId)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        $extendedState->clearEvents();
        $extendedState->save();
    }

    /**
     * Stores the table that maps the items identifiers to item reference
     * Fallback index in case of the delivery was compiled without the index of item href
     * @param $testSessionId
     * @param array $table
     * @throws \common_Exception
     */
    public function storeItemHrefIndex($testSessionId, $table)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        $extendedState->setItemHrefIndex($table);
        $extendedState->save();
    }

    /**
     * Loads the table that maps the items identifiers to item reference
     * Fallback index in case of the delivery was compiled without the index of item href
     * @param $testSessionId
     * @return array
     * @throws \common_Exception
     */
    public function loadItemHrefIndex($testSessionId)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        return $extendedState->getItemHrefIndex();
    }

    /**
     * Set a CAT Value
     *
     * Set a CAT value in the Extended State.
     *
     * @param string $testSessionId
     * @param string $assessmentSectionId
     * @param string $key
     * @param string $value
     * @throws \common_Exception
     */
    public function setCatValue($testSessionId, $assessmentSectionId, $key, $value)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        $extendedState->setCatValue($assessmentSectionId, $key, $value);
        $extendedState->save();
    }

    /**
     * Get a CAT Value
     *
     * Get a CAT value from the Extended State.
     *
     * @param string $testSessionId
     * @param string $assessmentSectionId
     * @param string $key
     * @return string
     * @throws \common_Exception
     */
    public function getCatValue($testSessionId, $assessmentSectionId, $key)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        return $extendedState->getCatValue($assessmentSectionId, $key);
    }

    /**
     * Remove a CAT value from the ExtendedState.
     *
     * @param string $testSessionId
     * @param string $assessmentSectionId
     * @param string $key
     * @throws \common_Exception
     */
    public function removeCatValue($testSessionId, $assessmentSectionId, $key)
    {
        $extendedState = $this->getExtendedState($testSessionId);
        $extendedState->removeCatValue($assessmentSectionId, $key);
        $extendedState->save();
    }

    /**
     * @inheritdoc
     */
    public function deleteDeliveryExecutionData(DeliveryExecutionDeleteRequest $request)
    {
        if ($request->getSession() === null) {
            $sessionId = $request->getDeliveryExecution()->getIdentifier();
        } else {
            $sessionId = $request->getSession()->getSessionId();
        }
        $extendedState = $this->getExtendedState($sessionId);
        $extendedState->deleteDeliveryExecutionData($request);

        return $this->getStorageService()->persist($extendedState->getUserId(), $extendedState->getStorageKey());
    }
}


<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2016 (original work) Open Assessment Technologies SA ;
 */
/**
 * @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
 */

namespace oat\taoQtiTest\models\runner\time;

use oat\taoTests\models\runner\time\ArraySerializable;
use oat\taoTests\models\runner\time\IncompleteRangeException;
use oat\taoTests\models\runner\time\InconsistentRangeException;
use oat\taoTests\models\runner\time\InvalidDataException;
use oat\taoTests\models\runner\time\MalformedRangeException;
use oat\taoTests\models\runner\time\TimeException;
use oat\taoTests\models\runner\time\TimeLine;
use oat\taoTests\models\runner\time\TimePoint;

/**
 * Class QtiTimeLine
 * @package oat\taoQtiTest\models\runner\time
 */
class QtiTimeLine implements TimeLine, ArraySerializable, \Serializable, \JsonSerializable
{
    /**
     * The list of TimePoint representing the TimeLine
     * @var array
     */
    protected $points = [];

    /**
     * QtiTimeLine constructor.
     * @param array $points
     */
    public function __construct($points = null)
    {
        if (isset($points)) {
            foreach ($points as $point) {
                $this->add($point);
            }
        }
    }

    /**
     * Exports the internal state to an array
     * @return array
     */
    public function toArray()
    {
        $data = [];
        foreach ($this->points as $point) {
            $data[] = $point->toArray();
        }
        return $data;
    }

    /**
     * Imports the internal state from an array
     * @param array $data
     */
    public function fromArray($data)
    {
        $this->points = [];
        if (is_array($data)) {
            foreach ($data as $dataPoint) {
                $point = new TimePoint();
                $point->fromArray($dataPoint);
                $this->points[] = $point;
            }
        }
    }

    /**
     * Specify data which should be serialized to JSON
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
     * @return mixed data which can be serialized by json_encode,
     * which is a value of any type other than a resource.
     * @since 5.4.0
     */
    public function jsonSerialize()
    {
        return $this->toArray();
    }

    /**
     * String representation of object
     * @link http://php.net/manual/en/serializable.serialize.php
     * @return string the string representation of the object or null
     * @since 5.1.0
     */
    public function serialize()
    {
        return serialize($this->points);
    }

    /**
     * Constructs the object
     * @link http://php.net/manual/en/serializable.unserialize.php
     * @param string $serialized

     * The string representation of the object.
     *

     * @return void
     * @since 5.1.0
     * @throws InvalidDataException
     */
    public function unserialize($serialized)
    {
        $this->points = unserialize($serialized);
        if (!is_array($this->points)) {
            throw new InvalidDataException('The provided serialized data are invalid!');
        }
    }

    /**
     * Gets the list of TimePoint present in the TimeLine
     * @return array
     */
    public function getPoints()
    {
        return $this->points;
    }

    /**
     * Adds another TimePoint inside the TimeLine
     * @param TimePoint $point
     * @return TimeLine
     */
    public function add(TimePoint $point)
    {
        $this->points[] = $point;
        return $this;
    }

    /**
     * Removes all TimePoint corresponding to the provided criteria
     * @param string|array $tag A tag or a list of tags to filter
     * @param int $target The type of target TimePoint to filter
     * @param int $type The tyoe of TimePoint to filter
     * @return int Returns the number of removed TimePoints
     */
    public function remove($tag, $target = TimePoint::TARGET_ALL, $type = TimePoint::TYPE_ALL)
    {
        $tags = is_array($tag) ? $tag : [$tag];
        $removed = 0;
        foreach ($this->points as $idx => $point) {
            if ($point->match($tags, $target, $type)) {
                unset($this->points[$idx]);
                $removed++;
            }
        }
        return $removed;
    }

    /**
     * Clears the TimeLine from all its TimePoint
     * @return TimeLine
     */
    public function clear()
    {
        $this->points = [];
        return $this;
    }

    /**
     * Gets a filtered TimeLine, containing the TimePoint corresponding to the provided criteria
     * @param string|array $tag A tag or a list of tags to filter
     * @param int $target The type of target TimePoint to filter
     * @param int $type The type of TimePoint to filter
     * @return TimeLine Returns a subset corresponding to the found TimePoints
     */
    public function filter($tag = null, $target = TimePoint::TARGET_ALL, $type = TimePoint::TYPE_ALL)
    {
        // the tag criteria can be omitted
        $tags = null;
        if (isset($tag)) {
            $tags = is_array($tag) ? $tag : [$tag];
        }

        // create a another instance of the same class
        $subset = new static();

        // fill the new instance with filtered TimePoint
        foreach ($this->points as $idx => $point) {
            if ($point->match($tags, $target, $type)) {
                $subset->add($point);
            }
        }

        return $subset;
    }

    /**
     * Finds all TimePoint corresponding to the provided criteria
     * @param string|array $tag A tag or a list of tags to filter
     * @param int $target The type of target TimePoint to filter
     * @param int $type The type of TimePoint to filter
     * @return array Returns a list of the found TimePoints
     */
    public function find($tag = null, $target = TimePoint::TARGET_ALL, $type = TimePoint::TYPE_ALL)
    {
        // the tag criteria can be omitted
        $tags = null;
        if (isset($tag)) {
            $tags = is_array($tag) ? $tag : [$tag];
        }

        // gather filterer TimePoint
        $points = [];
        foreach ($this->points as $point) {
            if ($point->match($tags, $target, $type)) {
                $points [] = $point;
            }
        }
        return $points;
    }

    /**
     * Computes the total duration represented by the filtered TimePoints
     * @param string|array $tag A tag or a list of tags to filter
     * @param int $target The type of target TimePoint to filter
     * @param int $lastTimestamp An optional timestamp that will be utilized to close the last open range, if any
     * @return float Returns the total computed duration
     * @throws TimeException
     */
    public function compute($tag = null, $target = TimePoint::TARGET_ALL, $lastTimestamp = 0)
    {
        // default value for the last timestamp
        if (!$lastTimestamp) {
            $lastTimestamp = microtime(true);
        }

        // either get all points or only a subset according to the provided criteria
        if (!$tag && $target == TimePoint::TARGET_ALL) {
            $points = $this->getPoints();
        } else {
            $points = $this->find($tag, $target, TimePoint::TYPE_ALL);
        }

        // we need a ordered list of points
        TimePoint::sort($points);

        // gather points by ranges, relying on the points references
        $ranges = [];
        foreach ($points as $point) {
            $ranges[$point->getRef()][] = $point;
        }

        // compute the total duration by summing all gathered ranges
        // this loop can throw exceptions
        $duration = 0;
        foreach ($ranges as $range) {

            // the last range could be still open, or some range could be malformed due to connection issues...
            $range = $this->fixRange($range, $lastTimestamp);

            // compute the duration of the range, an exception may be thrown if the range is malformed
            // possible errors are (but should be avoided by the `fixRange()` method):
            // - unclosed range: should be autoclosed by fixRange
            // - unsorted points or nested/blended ranges: should be corrected by fixRange
            $duration += $this->computeRange($range);
        }

        return $duration;
    }

    /**
     * Compute the duration of a range of TimePoint
     * @param array $range
     * @return float
     * @throws IncompleteRangeException
     * @throws InconsistentRangeException
     * @throws MalformedRangeException
     */
    protected function computeRange($range)
    {
        // a range must be built from pairs of TimePoint
        if (count($range) % 2) {
            throw new IncompleteRangeException();
        }

        $duration = 0;
        $start = null;
        $end = null;
        foreach ($range as $point) {
            // grab the START TimePoint
            if ($this->isStartPoint($point)) {
                // we cannot have the START TimePoint twice
                if ($start) {
                    throw new MalformedRangeException('A time range must be defined by a START and a END TimePoint! Twice START found.');
                }
                $start = $point;
            }

            // grab the END TimePoint
            if ($this->isEndPoint($point)) {
                // we cannot have the END TimePoint twice
                if ($end) {
                    throw new MalformedRangeException('A time range must be defined by a START and a END TimePoint! Twice END found.');
                }
                $end = $point;
            }

            // when we have got START and END TimePoint, compute the duration
            if ($start && $end) {
                $duration += $this->getRangeDuration($start, $end);
                $start = null;
                $end = null;
            }
        }

        return $duration;
    }

    /**
     * Ensures the ranges are well formed. They should have been sorted before, otherwise the process won't work.
     * Tries to fix a range by adding missing points
     * @param array $range
     * @param float $lastTimestamp - An optional timestamp to apply on the last TimePoint if missing
     * @return array
     */
    protected function fixRange($range, $lastTimestamp = null)
    {
        $fixedRange = [];
        $last = null;
        $open = false;

        foreach ($range as $point) {
            if ($this->isStartPoint($point)) {              // start of range
                // the last range could be still open...
                if ($last && $open) {
                    $fixedRange[] = $this->cloneTimePoint($point, TimePoint::TYPE_END);
                }
                $open = true;
            } else if ($this->isEndPoint($point)) {         // end of range
                // this range could not be started...
                if (!$open) {
                    $fixedRange[] = $this->cloneTimePoint($last ? $last : $point, TimePoint::TYPE_START);
                }
                $open = false;
            }
            $fixedRange[] = $point;
            $last = $point;
        }

        // the last range could be still open...
        if ($last && $open) {
            $fixedRange[] = $this->cloneTimePoint($last, TimePoint::TYPE_END, $lastTimestamp);
        }

        return $fixedRange;
    }

    /**
     * Makes a copy of a TimePoint and forces a particular type
     * @param TimePoint $point - The point to duplicate
     * @param int $type - The type of the new point. It should be different!
     * @param float $timestamp - An optional timestamp to set on the new point. By default keep the source timestamp.
     * @return TimePoint
     */
    protected function cloneTimePoint(TimePoint $point, $type, $timestamp = null)
    {
        if (is_null($timestamp)) {
            $timestamp = $point->getTimestamp();
        }
        \common_Logger::d("Create missing TimePoint at " . $timestamp);
        return new TimePoint($point->getTags(), $timestamp, $type, $point->getTarget());
    }

    /**
     * Tells if this is a start TimePoint
     * @param TimePoint $point
     * @return bool
     */
    protected function isStartPoint(TimePoint $point)
    {
        return $point->match(null, TimePoint::TARGET_ALL, TimePoint::TYPE_START);
    }

    /**
     * Tells if this is a end TimePoint
     * @param TimePoint $point
     * @return bool
     */
    protected function isEndPoint(TimePoint $point)
    {
        return $point->match(null, TimePoint::TARGET_ALL, TimePoint::TYPE_END);
    }

    /**
     * Computes the duration between two TimePoint
     * @param TimePoint $start
     * @param TimePoint $end
     * @return float
     * @throws InconsistentRangeException
     */
    protected function getRangeDuration($start, $end)
    {
        // the two TimePoint must have the same target to be consistent
        if ($start->getTarget() != $end->getTarget()) {
            throw new InconsistentRangeException('A time range must be defined by two TimePoint with the same target');
        }

        // the two TimePoint must be correctly ordered
        $rangeDuration = $end->getTimestamp() - $start->getTimestamp();
        if ($rangeDuration < 0) {
            throw new InconsistentRangeException('A START TimePoint cannot take place after the END!');
        }

        return $rangeDuration;
    }
}