<?php

/*
 * Copyright (C) 2015-2025 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 Exception;
use OPNsense\Base\FieldTypes\ContainerField;
use OPNsense\Base\ModelException;
use OPNsense\Core\AppConfig;
use OPNsense\Core\Config;
use OPNsense\Core\Syslog;
use ReflectionClass;
use ReflectionException;
use SimpleXMLElement;

/**
 * Class BaseModel implements base model to bind config and definition to object.
 * Derive from BaseModel to create usable models.
 * Every model definition should include a class (derived from BaseModel) and a xml model to define the data (model.xml)
 *
 * See the HelloWorld model for a full implementation.
 * (https://github.com/opnsense/plugins/tree/master/devel/helloworld/src/opnsense/mvc/app/models/OPNsense/HelloWorld)
 *
 * @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 = '';

    /**
     * this models version number, defaults to 0.0.0 (no version)
     * @var string
     */
    private $internal_model_version = "0.0.0";

    /**
     * this model's description
     * @var string
     */
    private $internal_model_descr = "";

    /**
     * prefix for migration files, default is M (e.g. M1_0_0.php equals version 1.0.0)
     * when models share a namespace, they should be allowed to use their own unique prefix
     * @var string
     */
    private $internal_model_migration_prefix = "M";

    /**
     * model version in config.xml
     * @var null
     */
    private $internal_current_model_version = null;

    /**
     * cache classes and their metadata per reference
     * @var null
     */
    private static $internalCacheReflectionClasses = null;

    /**
     * uuid missing on load
     * @var bool
     */
    private $internalMissingUuids = false;

    /**
     * @var int internal validation sequence (number of times validation has run)
     */
    private int $internalValidationSequence = 0;

    /**
     * skip dynamic operations, not required for the model itself, when requested.
     *
     */
    private $internalForceLazyLoading = false;

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

    /**
     * @return bool if lazy loaded so our model may skip some user facing data collection
     */
    public function isLazyLoaded()
    {
        return $this->internalForceLazyLoading;
    }

    /**
     * parse option data for model setter.
     * @param $xmlNode
     * @return array|string
     */
    private function parseOptionData($xmlNode)
    {
        if ($xmlNode->count() == 0) {
            $result = (string)$xmlNode;
        } else {
            $result = [];
            foreach ($xmlNode->children() as $childNode) {
                // item keys can be overwritten using value attributes
                if (!isset($childNode->attributes()['value'])) {
                    $itemKey = (string)$childNode->getName();
                } else {
                    $itemKey = (string)$childNode->attributes()['value'];
                }
                $result[$itemKey] = $this->parseOptionData($childNode);
            }
        }
        return $result;
    }

    /**
     * fetch reflection class (cached by field type)
     * @param SimpleXMLElement $xmlNode model node
     * @param string $thisModelPath reference to the node (a.b.c)
     * @return BaseField type class
     * @throws ModelException when unable to parse field type
     * @throws ReflectionException when unable to create class
     */
    private function getFieldObject($xmlNode, $thisModelPath, $new_ref)
    {
        if (self::$internalCacheReflectionClasses === null) {
            self::$internalCacheReflectionClasses = [];
        }
        if (!isset(self::$internalCacheReflectionClasses[$thisModelPath])) {
            // every item results in a Field type object, the first step is to determine which object to create
            // based on the input model spec
            $xmlNodeType = $xmlNode->attributes()["type"];
            if (!empty($xmlNodeType)) {
                // construct field type object
                if (strpos($xmlNodeType, "\\") !== false) {
                    // application specific field type contains path separator
                    if (strpos($xmlNodeType, ".\\") === 0) {
                        // use current namespace (.\Class)
                        $namespace = explode("\\", get_class($this));
                        array_pop($namespace);
                        $namespace = implode("\\", $namespace);
                        $classname = str_replace(".\\", $namespace . "\\FieldTypes\\", (string)$xmlNodeType);
                    } else {
                        $classname = (string)$xmlNodeType;
                    }
                } else {
                    // standard field type
                    $classname = "OPNsense\\Base\\FieldTypes\\" . $xmlNodeType;
                }
            } else {
                // no type defined, so this must be a standard container (without content)
                $classname = 'OPNsense\Base\FieldTypes\ContainerField';
            }

            $is_derived_from_basefield = false;
            if (class_exists($classname)) {
                $field_rfcls = new ReflectionClass($classname);
                $check_derived = $field_rfcls->getParentClass();
                while ($check_derived != false) {
                    if ($check_derived->name == 'OPNsense\Base\FieldTypes\BaseField') {
                        $is_derived_from_basefield = true;
                        break;
                    }
                    $check_derived = $check_derived->getParentClass();
                }
            } else {
                throw new ModelException("class " . $classname . " missing");
            }
            if (!$is_derived_from_basefield) {
                // class found, but of wrong type. raise an exception.
                throw new ModelException("class " . $field_rfcls->name . " of wrong type in model definition");
            }
            $field_methods = [];
            /**
             * cache field setters per module path, these can't change over iterations
             */
            foreach ($xmlNode->children() as $fieldMethod) {
                $method_name = "set" . $fieldMethod->getName();
                if ($field_rfcls->hasMethod($method_name)) {
                    $field_methods[$method_name] = $this->parseOptionData($fieldMethod);
                }
            }
            $field_is_volatile = ($xmlNode->attributes()["volatile"] ?? '') == 'true';
            $node_name = $xmlNode->getName();
            self::$internalCacheReflectionClasses[$thisModelPath] = [
                'cls' => $field_rfcls,
                'methods' => $field_methods,
                'is_volatile' => $field_is_volatile,
                'name' => $node_name
            ];
        } else {
            $field_rfcls = self::$internalCacheReflectionClasses[$thisModelPath]['cls'];
            $field_methods = self::$internalCacheReflectionClasses[$thisModelPath]['methods'];
            $field_is_volatile = self::$internalCacheReflectionClasses[$thisModelPath]['is_volatile'];
            $node_name  = self::$internalCacheReflectionClasses[$thisModelPath]['name'];
        }
        $fieldObject = $field_rfcls->newInstance($new_ref, $node_name);
        $fieldObject->setParentModel($this);
        if ($field_is_volatile) {
            $fieldObject->setInternalIsVolatile();
        }
        if (!$fieldObject->isContainer()) {
            foreach ($field_methods as $method_name => $payload) {
                $fieldObject->$method_name($payload);
            }
        }
        return $fieldObject;
    }

    /**
     * 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
     * @throws ReflectionException
     */
    private function parseXml(&$xml, &$config_data, &$internal_data, $model_path = '')
    {
        // copy xml tag attributes to Field
        foreach ($config_data?->attributes() ?? [] as $AttrKey => $AttrValue) {
            $internal_data->setAttributeValue($AttrKey, (string)$AttrValue);
        }

        // iterate model children
        foreach ($xml->children() as $xmlNode) {
            $tagName = $xmlNode->getName();
            $thisModelPath = empty($model_path) ? get_class($this) . "." . $tagName : $model_path . '.' . $tagName;
            // generate full object name ( section.section.field syntax ) and create new Field
            $new_ref = $internal_data->__reference == "" ? $tagName : $internal_data->__reference . "." . $tagName;
            $fieldObject = $this->getFieldObject($xmlNode, $thisModelPath, $new_ref);

            // now add content to this model (recursive)
            $config_section_data = $config_data?->$tagName ?? null;
            if (!$fieldObject->isContainer()) {
                $internal_data->addChildNode($tagName, $fieldObject);
                if ($config_section_data !== null) {
                    /* do not set initial data, default will take care */
                    $fieldObject->setValue($config_section_data);
                }
            } else {
                // add new child node container, always try to pass config data
                if ($fieldObject->isArrayType()) {
                    // handle Array types, recurring items
                    $node_count = 0;
                    foreach ($config_section_data ?? [] as $conf_section) {
                        if ($conf_section->count() == 0) {
                            // skip empty nodes: prevents legacy empty tags from being treated as invalid content items
                            // (migration will drop these anyway)
                            continue;
                        }
                        $node_count++;
                        // Array items are identified by a UUID, read from attribute or create a new one
                        $tagUUID = (string)$conf_section->attributes()['uuid'] ?? '';
                        $new_uuid = false;
                        if (empty($tagUUID)) {
                            $tagUUID = $internal_data->generateUUID();
                            $this->internalMissingUuids = true;
                            $new_uuid = true;
                        }

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

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

    /**
     * fetch model definition after basic validations
     * @return SimpleXMLElement
     * @throws ModelException if the model xml is not found or invalid
     * @throws ReflectionException
     */
    private function getModelXML()
    {
        // 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');
        }
        if (!$model_xml->mount) {
            throw new ModelException('model xml ' . $model_filename . ' missing mount definition');
        }
        return $model_xml;
    }

    /**
     * use model xml to find persisted_at for this model in the active configuration
     * @return float last persisted at timestamp, -1 when uncacheable (isLegacyMapper)
     */
    public static function persistedAt()
    {
        $class_info = new ReflectionClass(get_called_class());
        $model_filename = substr($class_info->getFileName(), 0, strlen($class_info->getFileName()) - 3) . "xml";
        $model_xml = @simplexml_load_file($model_filename);
        if ($model_xml != null && !empty($model_xml->mount)) {
            if (strpos($model_xml->mount, "//") === 0) {
                $src_mountpoint = $model_xml->mount;
            } elseif (str_ends_with($model_xml->mount, '+') && strpos($model_xml->mount, "//") !== 0) {
                return -1;
            } else {
                $src_mountpoint = "/opnsense{$model_xml->mount}";
            }

            $tmp_config_data = Config::getInstance()->xpath($src_mountpoint);
            if ($tmp_config_data !== false && $tmp_config_data->length > 0) {
                $config_array = simplexml_import_dom($tmp_config_data->item(0));
                return floatval((string)$config_array->attributes()['persisted_at']);
            }
        }
        return 0.0;
    }

    /**
     * Get the cache file name for the model data storage
     * @return string model cache filename
     */
    private static function getCacheFileName($tmpdir = null)
    {
        return sprintf(
            '%s/mdl_cache_%s.json',
            (new AppConfig())->application->tempDir,
            str_replace("\\", "_", (new ReflectionClass(get_called_class()))->getName())
        );
    }

    /**
     * Return the cached data of this object, excluding any dynamic data which lazy loading would exclude.
     * Values returned are collected via getDescription() on the nodes as this is the data the user is normally
     * presented.
     * @return array
     */
    public static function getCachedData()
    {
        $class_info = new ReflectionClass(get_called_class());
        $persisted_at = self::persistedAt();
        if ($persisted_at == -1) {
            return $class_info->newInstance(true)->getNodeContent();
        }
        $cache_filename = self::getCacheFileName();
        $fobj = new \OPNsense\Core\FileObject($cache_filename, 'a+', 0660, LOCK_EX, 'wwwonly:wheel');
        $cache_payload = $fobj->readJson() ?? [];
        if (!isset($cache_payload['persisted_at']) || $cache_payload['persisted_at'] != $persisted_at) {
            /**
             * cache invalid or expired, calculate new content
             * We assume we don't need dynamic content as the cost might be high and is certainly not cacheable
             * using the same logic.
             **/
            $mdl = $class_info->newInstance(true);
            $cache_payload = [
                'persisted_at' => self::persistedAt(),
                'content' => $mdl->getNodeContent()
            ];
            $fobj->truncate(0)->writeJson($cache_payload);
            unset($fobj);
        }
        return $cache_payload['content'];
    }

    /**
     * Remove cache file.
     *
     * When cached data is used, we can only determine the validity of the stored data by looking at the configuration,
     * in case the object also gathers dynamic data, the components responsible for pushing that data should invalidate
     * the case in order to appear in the set.
     */
    public static function flushCacheData()
    {
        @unlink(self::getCacheFileName());
    }

    /**
     * Construct new model type, using its own xml template
     * @throws ModelException if the model xml is not found or invalid
     * @throws ReflectionException
     */
    public function __construct($lazyload = false)
    {
        $this->internalForceLazyLoading = $lazyload;
        // setup config handle to singleton config singleton
        $internalConfigHandle = Config::getInstance();

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

        $model_xml = $this->getModelXML();
        if (!empty($model_xml->version)) {
            $this->internal_model_version = (string)$model_xml->version;
        }
        if (!empty($model_xml->description)) {
            $this->internal_model_descr = trim(preg_replace('/\s+/', ' ', (string)$model_xml->description));
        }
        if (!empty($model_xml->migration_prefix)) {
            $this->internal_model_migration_prefix = (string)$model_xml->migration_prefix;
        }

        $this->internal_mountpoint = $model_xml->mount;
        $config_array = new SimpleXMLElement('<opnsense/>');

        if ($this->isLegacyMapper()) {
            $xpath = "/opnsense" . rtrim($model_xml->mount, '+');
            $to_dom = dom_import_simplexml($config_array);
            foreach ($internalConfigHandle->xpath($xpath) as $node) {
                $to_dom->appendChild($to_dom->ownerDocument->importNode($node, true));
            }
        } elseif (!$this->isVolatile()) {
            /*
             *  XXX: we should probably replace start with // for absolute root, but to limit impact only select root for
             *       mountpoints starting with a single /
             */
            if (strpos($model_xml->mount, "//") === 0) {
                $src_mountpoint = $model_xml->mount;
            } else {
                $src_mountpoint = "/opnsense{$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($src_mountpoint);
            if ($tmp_config_data->length > 0) {
                $config_array = simplexml_import_dom($tmp_config_data->item(0));
            }
        }

        // We've loaded the model template, now let's parse it into this object
        $this->parseXml($model_xml->items, $config_array, $this->internalData);
        // root may contain a version, store if found
        if (empty($config_array)) {
            // new node, reset
            $this->internal_current_model_version = "0.0.0";
        } elseif (!empty($config_array->attributes()['version'])) {
            $this->internal_current_model_version = (string)$config_array->attributes()['version'];
        }

        // 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();
    }

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

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

    /**
     * iterate (non virtual) child nodes
     * @return mixed
     */
    public function iterateItems()
    {
        return $this->internalData->iterateItems();
    }

    /**
     * iterate (non virtual) child nodes recursively
     * @return mixed
     */
    public function iterateRecursiveItems()
    {
        return $this->internalData->iterateRecursiveItems();
    }

    /**
     * check if the model is not persistent in the config
     * @return bool true if memory model, false if config is stored
     */
    public function isVolatile()
    {
        return $this->internal_mountpoint == ':memory:';
    }

    /**
     * check if the model maps a legacy model without a container. these should operate similar as
     * regular models, but without a migration or version number (due to the lack of a container)
     * @return bool
     */
    public function isLegacyMapper()
    {
        return str_ends_with($this->internal_mountpoint, '+') && strpos($this->internal_mountpoint, "//") !== 0;
    }

    /**
     * Return the number of times performValidation() has been called.
     * This can be practical if validations need to cache outcomes which are consistent for the full validation
     * sequence.
     * @return int
     */
    public function getValidationSequence()
    {
        return $this->internalValidationSequence;
    }

    /**
     * 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 Group
     */
    public function performValidation($validateFullModel = false)
    {
        // create a wrapped validator and collect all model validations.
        $validation = new \OPNsense\Base\Validation();
        $all_nodes = $this->internalData->getFlatNodes();
        $validation_data = [];

        $this->internalValidationSequence++;

        foreach ($all_nodes as $key => $node) {
            if ($validateFullModel || $node->isFieldChanged()) {
                $node_validators = $node->getValidators();
                foreach ($node_validators as $item_validator) {
                    if (is_a($item_validator, "OPNsense\\Base\\Constraints\\BaseConstraint")) {
                        $target_key = $item_validator->getOption("node")->__reference;
                        $validation->add($target_key, $item_validator);
                    } else {
                        $validation->add($key, $item_validator);
                    }
                }
                if (count($node_validators) > 0) {
                    $validation_data[$key] = $node->getValue();
                }
            }
        }

        return $validation->validate($validation_data);
    }

    /**
     * perform a validation on changed model fields, using the (renamed) internal reference as a source pointer
     * for the requestor to identify its origin
     * @param null|string $sourceref source reference, for example model.section
     * @param string $targetref target reference, for example section. used as prefix if no source given
     * @return array list of validation errors, indexed by field reference
     */
    public function validate($sourceref = null, $targetref = '', $validateFullModel = false)
    {
        $result = [];
        $valMsgs = $this->performValidation($validateFullModel);
        foreach ($valMsgs as $msg) {
            // replace absolute path to attribute for relative one at uuid.
            if ($sourceref != null) {
                $fieldnm = str_replace($sourceref, $targetref, $msg->getField());
                $result[$fieldnm] = $msg->getMessage();
            } else {
                $fieldnm = $targetref . $msg->getField();
                $result[$fieldnm] = $msg->getMessage();
            }
        }
        return $result;
    }

    /**
     * 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

        if ($this->isVolatile() || $this->isLegacyMapper()) {
            $xml = new SimpleXMLElement('<root/>');
            $this->internalData->addToXMLNode($xml);
        } else {
            $xml_root_node = "";
            $parts = explode("/", ltrim($this->internal_mountpoint, "/"));
            foreach ($parts as $part) {
                $xml_root_node .= "<" . $part . ">";
            }
            foreach (array_reverse($parts) as $part) {
                $xml_root_node .= "</" . $part . ">";
            }
            $xml = new SimpleXMLElement($xml_root_node);
            $this->internalData->addToXMLNode($xml->xpath($this->internal_mountpoint)[0]);
            // add this model's version to the newly created xml structure
            if (!empty($this->internal_current_model_version)) {
                $rootnode = $xml->xpath($this->internal_mountpoint)[0];
                $rootnode->addAttribute('version', $this->internal_current_model_version);
                /* when versioned, also mark node with a timestamp */
                $rootnode->addAttribute('persisted_at', sprintf("%0.2f", microtime(true)));
                if (!empty($this->internal_model_descr)) {
                    $rootnode->addAttribute('description', $this->internal_model_descr);
                }
            }
        }

        return $xml;
    }

    /**
     * serialize model singleton to config object
     */
    private function internalSerializeToConfig()
    {
        // serialize this model's data to xml
        $data_xml = $this->toXML();

        $target_node = Config::getInstance()->object();
        if ($this->isLegacyMapper()) {
            /**
             *  Merge xml node, try to keep them in the same area of the xml file to lower the diff size.
             *  First we collect all new nodes in an array, then seek the ones we know and replace, remove access
             *  (when we end up with less nodes). Finally append new nodes not merged yet.
             */
            $xpath = "/opnsense" . rtrim($this->internal_mountpoint, '+');
            $toDom = dom_import_simplexml($target_node);
            $newNodes = [];
            foreach ($data_xml->children() as $node) {
                $newNodes[] = dom_import_simplexml($node[0]);
            }
            foreach ($target_node->xpath($xpath) as $idx => $node) {
                if (isset($newNodes[$idx])) {
                    $node = dom_import_simplexml($node);
                    $nodeImport = $toDom->ownerDocument->importNode($newNodes[$idx], true);
                    $node->parentNode->replaceChild($nodeImport, $node);
                    $newNodes[$idx] = null;
                } else {
                    unset($node[0]);
                }
            }
            /**
             * Target offset equals the parent of internal mountpoint.
             * e.g. /system/user should place new entries at /system
             **/
            $pxpath = implode("/", array_slice(explode("/", $xpath), 0, -1));
            $target = $target_node->xpath($pxpath);
            if (empty($target)) {
                /* target doesn't exist yet, create the root node by traversing the tree (starting inside /opnsense) */
                $target = $target_node;
                foreach (array_slice(explode("/", trim($xpath, "/")), 1, -1) as $p) {
                    $target = count($target_node->xpath($p)) == 0 ? $target->addChild($p) : $target->xpath($p)[0];
                }
            }
            $toDom = dom_import_simplexml($target[0]);
            foreach ($newNodes as $node) {
                if ($node !== null) {
                    $toDom->appendChild($toDom->ownerDocument->importNode($node, true));
                }
            }
        } else {
            // 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)
            foreach (explode("/", ltrim($this->internal_mountpoint, "/")) as $part) {
                if (count($target_node->xpath($part)) == 0) {
                    $target_node = $target_node->addChild($part);
                } else {
                    $target_node = $target_node->xpath($part)[0];
                }
            }
            // copy model data into config
            $toDom = dom_import_simplexml($target_node);
            $fromDom = dom_import_simplexml($source_node[0]);
            $nodeImport  = $toDom->ownerDocument->importNode($fromDom, true);
            $toDom->parentNode->replaceChild($nodeImport, $toDom);
        }
    }

    /**
     * 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!
     * @return bool persisted changes
     * @throws Validation\Exception validation errors
     */
    public function serializeToConfig($validateFullModel = false, $disable_validation = false)
    {
        // create logger to save possible consistency issues to
        $logger =  new Syslog('config', null, LOG_LOCAL2);

        // 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 (count($messages) > 0) {
            $exception_msg = "";
            foreach ($messages as $msg) {
                $exception_msg_part = "[" . get_class($this) . ":" . $msg->getField() . "] ";
                $exception_msg_part .= $msg->getMessage();
                $field_value = $this->getNodeByReference($msg->getField());
                if (!empty($field_value)) {
                    $exception_msg_part .= sprintf("{%s}", $field_value);
                }
                $exception_msg .= "$exception_msg_part\n";
                if (!$disable_validation) {
                    $logger->error($exception_msg_part);
                }
            }
            if (!$disable_validation) {
                throw new ValidationException($exception_msg);
            }
        }

        if ($this->isVolatile()) {
            return false;
        }

        $this->internalSerializeToConfig();
        return true;
    }

    /**
     * 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 ($node->hasChild($childName)) {
                $node = $node->getChild($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;
        }
    }

    /**
     * Execute model version migrations
     * Every model may contain a migrations directory containing BaseModelMigration descendants, which
     * are executed in order of version number.
     *
     * The BaseModelMigration class should be named with the corresponding version
     * prefixed with an M and . replaced by _ for example : M1_0_1 equals version 1.0.1
     *
     * @return bool status (true-->success, false-->failed)
     * @throws ReflectionException
     */
    public function runMigrations()
    {
        if ($this->isVolatile() || $this->isLegacyMapper()) {
            if ($this->isLegacyMapper() && $this->internalMissingUuids) {
                $this->serializeToConfig();
                return true;
            }
            return false;
        } elseif (version_compare($this->internal_current_model_version ?? '0.0.0', $this->internal_model_version, '<')) {
            $upgradePerformed = false;
            $migObjects = array();
            $logger =  new Syslog('config', null, LOG_LOCAL2);

            $class_info = new ReflectionClass($this);
            // fetch version migrations
            $versions = [];
            // set default migration for current model version
            $versions[$this->internal_model_version] = __DIR__ . "/BaseModelMigration.php";
            $migprefix = $this->internal_model_migration_prefix;
            foreach (glob(dirname($class_info->getFileName()) . "/Migrations/{$migprefix}*.php") as $filename) {
                $version = str_replace('_', '.', explode('.', substr(basename($filename), strlen($migprefix)))[0]);
                $versions[$version] = $filename;
            }

            uksort($versions, "version_compare");
            foreach ($versions as $mig_version => $filename) {
                if (
                    version_compare($this->internal_current_model_version ?? '0.0.0', $mig_version, '<') &&
                    version_compare($this->internal_model_version, $mig_version, '>=')
                ) {
                    // execute upgrade action
                    if (!strstr($filename, '/tests/app')) {
                        $mig_classname = explode('.', explode('/mvc/app/models', $filename)[1])[0];
                    } else {
                        // unit tests use a different namespace for their models
                        $mig_classname = "/tests" . explode('.', explode('/mvc/tests/app/models', $filename)[1])[0];
                    }
                    $mig_classname = str_replace('/', '\\', $mig_classname);
                    // Phalcon's autoloader uses _ as a directory locator, we need to import these files ourselves
                    require_once $filename;
                    $mig_class = new ReflectionClass($mig_classname);
                    $chk_class = empty($mig_class->getParentClass()) ? $mig_class :  $mig_class->getParentClass();
                    if ($chk_class->name == 'OPNsense\Base\BaseModelMigration') {
                        $migobj = $mig_class->newInstance();
                        try {
                            $migobj->run($this);
                            $migObjects[] = $migobj;
                            $upgradePerformed = true;
                        } catch (Exception $e) {
                            $logger->error("failed migrating from version " .
                                $this->getVersion() .  " to " . $mig_version . " in " .
                                $class_info->getName() .  " ( " . $e . " )");
                            /* fail migration when exceptions are thrown */
                            $this->internal_current_model_version = $mig_version;
                            return false;
                        }
                        $this->internal_current_model_version = $mig_version;
                    }
                }
            }
            // serialize to config after last migration step, keep the config data static as long as not all
            // migrations have completed.
            if ($upgradePerformed) {
                try {
                    $this->serializeToConfig();
                    foreach ($migObjects as $migobj) {
                        $migobj->post($this);
                    }
                } catch (Exception $e) {
                    $logger->error("Model " . $class_info->getName() . " can't be saved, skip ( " . $e . " )");
                    return false;
                }
            }

            return true;
        }
        return false;
    }

    /**
     * return current version number
     * @return null|string
     */
    public function getVersion()
    {
        return $this->internal_current_model_version ?? '<unversioned>';
    }

    /**
     * reset model to its defaults (flush all content)
     * @throws ModelException if the model xml is not found or invalid
     * @throws ReflectionException
     * @return this
     */
    public function Default()
    {
        $this->internalData = new ContainerField();
        $config_array = new SimpleXMLElement('<opnsense/>');
        $model_xml = $this->getModelXML();
        if (!empty($model_xml->version) && $this->internal_model_version != (string)$model_xml->version) {
            throw new ModelException('Unable to reset to defaults as model on disk is not the same as in memory');
        }
        $this->parseXml($model_xml->items, $config_array, $this->internalData);
        // trigger post loading event
        $this->internalData->eventPostLoading();
        return $this;
    }
}
