<?php
/**
*    Copyright (C) 2015 Deciso B.V.
*
*    All rights reserved.
*
*    Redistribution and use in source and binary forms, with or without
*    modification, are permitted provided that the following conditions are met:
*
*    1. Redistributions of source code must retain the above copyright notice,
*       this list of conditions and the following disclaimer.
*
*    2. Redistributions in binary form must reproduce the above copyright
*       notice, this list of conditions and the following disclaimer in the
*       documentation and/or other materials provided with the distribution.
*
*    THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
*    INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
*    AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
*    AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
*    OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
*    SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
*    INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
*    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
*    ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
*    POSSIBILITY OF SUCH DAMAGE.
*
*/

namespace OPNsense\Base;

use OPNsense\Base\FieldTypes\ArrayField;
use OPNsense\Base\FieldTypes\ContainerField;
use OPNsense\Core\Config;
use Phalcon\Logger\Adapter\Syslog;

/**
 * Class BaseModel implements base model to bind config and definition to object.
 * Derive from this class to create usable models.
 * Every model definition should include a class (derived from this) and a xml model to define the data (model.xml)
 *
 * See the Sample model for a full implementation.
 *
 * @package OPNsense\Base
 */
abstract class BaseModel
{
    /**
     * @var null|BaseField internal model data structure, should contain Field type objects
     */
    private $internalData = null;

    /**
     * place where the real data in the config.xml should live
     * @var string
     */
    private $internal_mountpoint = '';


    /**
     * If the model needs a custom initializer, override this init() method
     * Default behaviour is to do nothing in this init.
     */
    protected function init()
    {
        return ;
    }

    /**
     * parse option data for model setter.
     * @param $xmlNode
     * @return array|string
     */
    private function parseOptionData($xmlNode)
    {
        if ($xmlNode->count() == 0) {
            $result = $xmlNode->__toString();
        } else {
            $result = array();
            foreach ($xmlNode->children() as $childNode) {
                $result[$childNode->getName()] = $this->parseOptionData($childNode);
            }
        }
        return $result;
    }

    /**
     * parse model and config xml to object model using types in FieldTypes
     * @param SimpleXMLElement $xml model xml data (from items section)
     * @param SimpleXMLElement $config_data (current) config data
     * @param BaseField $internal_data output structure using FieldTypes,rootnode is internalData
     * @throws ModelException parse error
     */
    private function parseXml($xml, &$config_data, &$internal_data)
    {
        // copy xml tag attributes to Field
        if ($config_data != null) {
            foreach ($config_data->attributes() as $AttrKey => $AttrValue) {
                $internal_data->setAttributeValue($AttrKey, $AttrValue->__toString());
            }
        }

        // iterate model children
        foreach ($xml->children() as $xmlNode) {
            $tagName = $xmlNode->getName();
            // every item results in a Field type object, the first step is to determine which object to create
            // based on the input model spec
            $fieldObject = null ;
            $classname = "OPNsense\\Base\\FieldTypes\\".$xmlNode->attributes()["type"];
            if (class_exists($classname)) {
                // construct field type object
                $field_rfcls = new \ReflectionClass($classname);
                if (!$field_rfcls->getParentClass()->name == 'OPNsense\Base\FieldTypes\BaseField') {
                    // class found, but of wrong type. raise an exception.
                    throw new ModelException("class ".$field_rfcls->name." of wrong type in model definition");
                }
            } else {
                // no type defined, so this must be a standard container (without content)
                $field_rfcls = new \ReflectionClass('OPNsense\Base\FieldTypes\ContainerField');
            }

            // generate full object name ( section.section.field syntax ) and create new Field
            if ($internal_data->__reference == "") {
                $new_ref = $tagName;
            } else {
                $new_ref = $internal_data->__reference . "." . $tagName;
            }
            $fieldObject = $field_rfcls->newInstance($new_ref, $tagName);

            // now add content to this model (recursive)
            if ($fieldObject->isContainer() == false) {
                $internal_data->addChildNode($tagName, $fieldObject);
                if ($xmlNode->count() > 0) {
                    // if fieldtype contains properties, try to call the setters
                    foreach ($xmlNode->children() as $fieldMethod) {
                        $method_name = "set".$fieldMethod->getName();
                        if ($field_rfcls->hasMethod($method_name)) {
                            $fieldObject->$method_name($this->parseOptionData($fieldMethod));
                        }
                    }
                }
                if ($config_data != null && isset($config_data->$tagName)) {
                    // set field content from config (if available)
                    $fieldObject->setValue($config_data->$tagName->__toString());
                }
            } else {
                // add new child node container, always try to pass config data
                if ($config_data != null && isset($config_data->$tagName)) {
                    $config_section_data = $config_data->$tagName;
                } else {
                    $config_section_data = null ;
                }

                if ($fieldObject instanceof ArrayField) {
                    // handle Array types, recurring items
                    if ($config_section_data != null) {
                        foreach ($config_section_data as $conf_section) {
                            // Array items are identified by a UUID, read from attribute or create a new one
                            if (isset($conf_section->attributes()->uuid)) {
                                $tagUUID = $conf_section->attributes()['uuid']->__toString();
                            } else {
                                $tagUUID = $internal_data->generateUUID();
                            }


                            // iterate array items from config data
                            $child_node = new ContainerField($fieldObject->__reference . "." . $tagUUID, $tagName);
                            $this->parseXml($xmlNode, $conf_section, $child_node);
                            if (!isset($conf_section->attributes()->uuid)) {
                                // if the node misses a uuid, copy it to this nodes attributes
                                $child_node->setAttributeValue('uuid', $tagUUID);
                            }
                            $fieldObject->addChildNode($tagUUID, $child_node);
                        }
                    } else {
                        // There's no content in config.xml for this array node.
                        $tagUUID = $internal_data->generateUUID();
                        $child_node = new ContainerField($fieldObject->__reference . ".".$tagUUID, $tagName);
                        $child_node->setInternalIsVirtual();
                        $this->parseXml($xmlNode, $config_section_data, $child_node);
                        $fieldObject->addChildNode($tagUUID, $child_node);
                    }
                } else {
                    // All other node types (Text,Email,...)
                    $this->parseXml($xmlNode, $config_section_data, $fieldObject);
                }

                // add object as child to this node
                $internal_data->addChildNode($xmlNode->getName(), $fieldObject);
            }

        }

    }

    /**
     * Construct new model type, using it's own xml template
     * @throws ModelException if the model xml is not found or invalid
     */
    public function __construct()
    {
        // setup config handle to singleton config singleton
        $internalConfigHandle = Config::getInstance();

        // init new root node, all details are linked to this
        $this->internalData = new FieldTypes\ContainerField();

        // determine our caller's filename and try to find the model definition xml
        // throw error on failure
        $class_info = new \ReflectionClass($this);
        $model_filename = substr($class_info->getFileName(), 0, strlen($class_info->getFileName())-3) . "xml" ;
        if (!file_exists($model_filename)) {
            throw new ModelException('model xml '.$model_filename.' missing') ;
        }
        $model_xml = simplexml_load_file($model_filename);
        if ($model_xml === false) {
            throw new ModelException('model xml '.$model_filename.' not valid') ;
        }
        if ($model_xml->getName() != "model") {
            throw new ModelException('model xml '.$model_filename.' seems to be of wrong type') ;
        }
        $this->internal_mountpoint = $model_xml->mount;

        // use an xpath expression to find the root of our model in the config.xml file
        // if found, convert the data to a simple structure (or create an empty array)
        $tmp_config_data = $internalConfigHandle->xpath($model_xml->mount);
        if ($tmp_config_data->length > 0) {
            $config_array = simplexml_import_dom($tmp_config_data->item(0)) ;
        } else {
            $config_array = array();
        }

        // We've loaded the model template, now let's parse it into this object
        $this->parseXml($model_xml->items, $config_array, $this->internalData) ;

        // trigger post loading event
        $this->internalData->eventPostLoading();

        // call Model initializer
        $this->init();
    }

    /**
     * reflect getter to internalData (ContainerField)
     * @param string $name property name
     * @return mixed
     */
    public function __get($name)
    {
        return $this->internalData->$name;
    }

    /**
     * reflect setter to internalData (ContainerField)
     * @param string $name property name
     * @param string $value property value
     */
    public function __set($name, $value)
    {
        $this->internalData->$name = $value ;
    }

    /**
     * forward to root node's getFlatNodes
     * @return array all children
     */
    public function getFlatNodes()
    {
        return $this->internalData->getFlatNodes();
    }

    /**
     * get nodes as array structure
     * @return array
     */
    public function getNodes()
    {
        return $this->internalData->getNodes();
    }

    /**
     * structured setter for model
     * @param array|$data named array
     * @return array
     */
    public function setNodes($data)
    {
        return $this->internalData->setNodes($data);
    }

    /**
     * validate full model using all fields and data in a single (1 deep) array
     * @param bool $validateFullModel validate full model or only changed fields
     * @return \Phalcon\Validation\Message\Group
     */
    public function performValidation($validateFullModel = false)
    {
        // create a Phalcon validator and collect all model validations
        $validation = new \Phalcon\Validation();
        $validation_data = array();
        $all_nodes = $this->internalData->getFlatNodes();

        foreach ($all_nodes as $key => $node) {
            if ($validateFullModel || $node->isFieldChanged()) {
                $node_validators = $node->getValidators();
                foreach ($node_validators as $item_validator) {
                    $validation->add($key, $item_validator);
                }
                if (count($node_validators) > 0) {
                    $validation_data[$key] = $node->__toString();
                }
            }
        }

        if (count($validation_data) > 0) {
            $messages = $validation->validate($validation_data);
        } else {
            $messages = new \Phalcon\Validation\Message\Group();
        }

        return $messages;
    }

    /**
     * render xml document from model including all parent nodes.
     * (parent nodes are included to ease testing)
     *
     * @return \SimpleXMLElement xml representation of the model
     */
    public function toXML()
    {
        // calculate root node from mountpoint
        $xml_root_node = "";
        $str_parts = explode("/", str_replace("//", "/", $this->internal_mountpoint));
        for ($i=0; $i < count($str_parts); $i++) {
            if ($str_parts[$i] != "") {
                $xml_root_node .= "<".$str_parts[$i].">";
            }
        }
        for ($i=count($str_parts)-1; $i >= 0; $i--) {
            if ($str_parts[$i] != "") {
                $xml_root_node .= "</".$str_parts[$i].">";
            }
        }

        $xml = new \SimpleXMLElement($xml_root_node);
        $this->internalData->addToXMLNode($xml->xpath($this->internal_mountpoint)[0]);

        return $xml;

    }

    /**
     * serialize model singleton to config object
     */
    private function internalSerializeToConfig()
    {
        // setup config handle to singleton config singleton
        $internalConfigHandle = Config::getInstance();
        $config_xml = $internalConfigHandle->object();

        // serialize this model's data to xml
        $data_xml = $this->toXML();

        // Locate source node (in theory this must return a valid result, delivered by toXML).
        // Because toXML delivers the actual xml including the full path, we need to find the root of our data.
        $source_node = $data_xml->xpath($this->internal_mountpoint);

        // find parent of mountpoint (create if it doesn't exists)
        $target_node = $config_xml;
        $str_parts = explode("/", str_replace("//", "/", $this->internal_mountpoint));
        for ($i=0; $i < count($str_parts)-1; $i++) {
            if ($str_parts[$i] != "") {
                if (count($target_node->xpath($str_parts[$i])) == 0) {
                    $target_node = $target_node->addChild($str_parts[$i]);
                } else {
                    $target_node = $target_node->xpath($str_parts[$i])[0];
                }

            }
        }

        // copy model data into config
        $toDom = dom_import_simplexml($target_node);
        $fromDom = dom_import_simplexml($source_node[0]);

        // remove old model data and write new
        foreach ($toDom->getElementsByTagName($fromDom->nodeName) as $oldNode) {
            $toDom->removeChild($oldNode);
        }
        $toDom->appendChild($toDom->ownerDocument->importNode($fromDom, true));
    }

    /**
     * validate model and serialize data to config singleton object.
     *
     * @param bool $validateFullModel by default we only validate the fields we have changed
     * @param bool $disable_validation skip validation, be careful to use this!
     * @throws \Phalcon\Validation\Exception validation errors
     */
    public function serializeToConfig($validateFullModel = false, $disable_validation = false)
    {
        // create logger to save possible consistency issues to
        $logger = new Syslog("config", array(
            'option' => LOG_PID,
            'facility' => LOG_LOCAL4
        ));

        // Perform validation, collect all messages and raise exception if validation is not disabled.
        // If for some reason the developer chooses to ignore the errors, let's at least log there something
        // wrong in this model.
        $messages = $this->performValidation($validateFullModel);
        if ($messages->count() > 0) {
            $exception_msg = "";
            foreach ($messages as $msg) {
                $exception_msg .= "[".$msg-> getField()."] ".$msg->getMessage()."\n";
                // always log validation errors
                $logger->error(str_replace("\\", ".", get_class($this)).".".$msg-> getField(). " " .$msg->getMessage());
            }
            if (!$disable_validation) {
                throw new \Phalcon\Validation\Exception($exception_msg);
            }
        }
        $this->internalSerializeToConfig();
    }

    /**
     * find node by reference starting at the root node
     * @param string $reference node reference (point separated "node.subnode.subsubnode")
     * @return BaseField|null field node by reference (or null if not found)
     */
    public function getNodeByReference($reference)
    {
        $parts = explode(".", $reference);

        $node = $this->internalData;
        while (count($parts)>0) {
            $childName = array_shift($parts);
            if (array_key_exists($childName, $node->getChildren())) {
                $node = $node->getChildren()[$childName];
            } else {
                return null;
            }

        }
        return $node;
    }

    /**
     * set node value by name (if reference exists)
     * @param string $reference node reference (point separated "node.subnode.subsubnode")
     * @param string $value
     * @return bool value saved yes/no
     */
    public function setNodeByReference($reference, $value)
    {
        $node =$this->getNodeByReference($reference);
        if ($node != null) {
            $node->setValue($value);
            return true;
        } else {
            return false;
        }
    }
}