<?php
/**
 * Magento
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Open Software License (OSL 3.0)
 * that is bundled with this package in the file LICENSE.txt.
 * It is also available through the world-wide-web at this URL:
 * http://opensource.org/licenses/osl-3.0.php
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@magento.com so we can send you a copy immediately.
 *
 * DISCLAIMER
 *
 * Do not edit or add to this file if you wish to upgrade Magento to newer
 * versions in the future. If you wish to customize Magento for your
 * needs please refer to http://www.magento.com for more information.
 *
 * @category    Varien
 * @package     Varien_Object
 * @copyright  Copyright (c) 2006-2020 Magento, Inc. (http://www.magento.com)
 * @license    http://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
 */

/**
 * Varien Object
 *
 * @category   Varien
 * @package    Varien_Object
 * @author      Magento Core Team <core@magentocommerce.com>
 * @template I
 */
class Varien_Object implements ArrayAccess
{
    /**
     * Object attributes.
     *
     * @var Array<string, mixed>
     */
    protected array $_data = [];

    /**
     * Data changes flag (true after setData|unsetData call)
     */
    protected bool $_hasDataChanges = false;

    // TODO: Move to a child class? Currently used by some not inheriting from
    // Mage_Core_Model_Abstract
    /**
     * Name of object id field
     */
    protected ?string $_idFieldName = null;

    /**
     * Setter/Getter underscore transformation cache
     *
     * @var Array<string, string>
     */
    protected static array $_underscoreCache = [];

    /**
     * Object delete flag
     */
    protected bool $_isDeleted = false;

    /**
     * @var Array<string, bool>
     */
    protected array $_dirty = [];

    /**
     * Constructor
     *
     * By default is looking for first argument as array and assignes it as object attributes
     * This behaviour may change in child classes
     *
     * @param ?Array<string, mixed> $data
     */
    public function __construct(?array $data = null)
    {
        $this->_data = empty($data) ? [] : $data;

        $this->_construct();
    }

    /**
     * Internal constructor not depended on params. Can be used for object initialization
     *
     * @return void
     */
    protected function _construct()
    {
    }

    /**
     * Set _isDeleted flag value (if $isDeleted param is defined) and return current flag value
     */
    public function isDeleted(bool $isDeleted = null): bool
    {
        $result = $this->_isDeleted;
        if (!is_null($isDeleted)) {
            $this->_isDeleted = $isDeleted;
        }
        return $result;
    }

    /**
     * Get data change status
     */
    public function hasDataChanges(): bool
    {
        return $this->_hasDataChanges;
    }

    /**
     * Set name of object id field
     *
     * @return $this
     */
    public function setIdFieldName(string $name): self
    {
        $this->_idFieldName = $name;
        return $this;
    }

    /**
     * Retrieve name of object id field
     */
    public function getIdFieldName(): ?string
    {
        return $this->_idFieldName;
    }

    /**
     * Retrieve object id
     *
     * @return ?I
     */
    public function getId(): mixed
    {
        $fieldName = $this->getIdFieldName();
        if ($fieldName) {
            return $this->_getData($fieldName);
        }
        return $this->_getData('id');
    }

    /**
     * Set object id field value
     *
     * @param I $value
     * @return $this
     */
    public function setId(mixed $value): self
    {
        $fieldName = $this->getIdFieldName();
        if ($fieldName) {
            $this->setData($fieldName, $value);
        } else {
            $this->setData('id', $value);
        }
        return $this;
    }

    /**
     * Add data to the object.
     *
     * Retains previous data in the object.
     *
     * @param Array<string, mixed> $arr
     * @return $this
     */
    public function addData(array $arr): self
    {
        foreach($arr as $index=>$value) {
            $this->setData($index, $value);
        }
        return $this;
    }

    /**
     * Overwrite data in the object.
     *
     * $key can be string or array.
     * If $key is string, the attribute value will be overwritten by $value
     *
     * If $key is an array, it will overwrite all the data in the object.
     *
     * @param string|Array<string, mixed> $key
     * @return $this
     */
    public function setData(string|array $key, mixed $value = null): self
    {
        $this->_hasDataChanges = true;
        if(is_array($key)) {
            $this->_data = $key;
        } else {
            $this->_data[$key] = $value;
        }
        return $this;
    }

    /**
     * Unset data from the object.
     *
     * $key can be a string only. Array will be ignored.
     *
     * @return $this
     */
    public function unsetData(string $key = null): self
    {
        $this->_hasDataChanges = true;
        if (is_null($key)) {
            $this->_data = [];
        } else {
            unset($this->_data[$key]);
        }
        return $this;
    }

    /**
     * Retrieves data from the object
     *
     * If $key is empty will return all the data as an array
     * Otherwise it will return value of the attribute specified by $key
     *
     * If $index is specified it will assume that attribute data is an array
     * and retrieve corresponding member.
     */
    public function getData(string $key = ''): mixed
    {
        if ('' === $key) {
            return $this->_data;
        }

        // legacy accept a/b/c as ['a']['b']['c']
        if (str_contains($key, '/')) {
            return $this->_getDataPath($key);
        }

        if (isset($this->_data[$key])) {
            // legacy functionality for $index
            if(func_num_args() > 1) {
                return $this->_getDataIndex($key, func_get_arg(1));
            }

            return $this->_data[$key];
        }

        return null;
    }

    private function _getDataPath(string $key): mixed {
        $e = new Exception(sprintf("Deprecated %s path functionality", __METHOD__));

        Mage::logException($e, "varien_object", Zend_Log::WARN);

        // if(Mage::getIsDeveloperMode()) {
        //     throw $e;
        // }

        $keyArr = explode('/', $key);
        $data = $this->_data;
        foreach ($keyArr as $i=>$k) {
            if ($k==='') {
                return null;
            }
            if (is_array($data)) {
                if (!isset($data[$k])) {
                    return null;
                }
                $data = $data[$k];
            } elseif ($data instanceof Varien_Object) {
                $data = $data->getData($k);
            } else {
                return null;
            }
        }
        return $data;
    }

    private function _getDataIndex(string $key, mixed $index): mixed {

        $e = new Exception(sprintf("Deprecated %s index functionality", __METHOD__));

        Mage::logException($e, "varien_object", Zend_Log::WARN);

        // if(Mage::getIsDeveloperMode()) {
        //     throw $e;
        // }

        if($index === null || $index === false) {
            return $this->_data[$key];
        }

        $value = $this->_data[$key];
        if (is_array($value)) {
            /**
             * If we have any data, even if it empty - we should use it, anyway
             */
            if (isset($value[$index])) {
                return $value[$index];
            }
            return null;
        } elseif (is_string($value)) {
            $arr = explode("\n", $value);
            return (isset($arr[$index]) && (!empty($arr[$index]) || strlen($arr[$index]) > 0))
                ? $arr[$index] : null;
        } elseif ($value instanceof Varien_Object) {
            return $value->getData($index);
        }
        return null;
    }

    /**
     * Get value from _data array without parse key
     */
    protected function _getData(string $key): mixed
    {
        return isset($this->_data[$key]) ? $this->_data[$key] : null;
    }

    /**
     * Set object data with calling setter method
     *
     * @return $this
     */
    public function setDataUsingMethod(string $key, mixed $args = []): self
    {
        $method = 'set'.$this->_camelize($key);
        $this->$method($args);
        return $this;
    }

    /**
     * Get object data by key with calling getter method
     */
    public function getDataUsingMethod(string $key, mixed $args = null): mixed
    {
        $method = 'get'.$this->_camelize($key);
        return $args === null ? $this->$method() : $this->$method($args);
    }

    /**
     * Fast get data or set default if value is not available
     */
    public function getDataSetDefault(string $key, mixed $default): mixed
    {
        if (!isset($this->_data[$key])) {
            $this->_data[$key] = $default;
        }
        return $this->_data[$key];
    }

    /**
     * If $key is empty, checks whether there's any data in the object
     * Otherwise checks if the specified attribute is set.
     */
    public function hasData(string $key = ''): bool
    {
        if (empty($key)) {
            return !empty($this->_data);
        }
        return array_key_exists($key, $this->_data);
    }

    /**
     * Convert object attributes to array
     *
     * @param Array<string> $arrAttributes array of required attributes
     */
    public function __toArray(array $arrAttributes = []): array
    {
        if (empty($arrAttributes)) {
            return $this->_data;
        }

        $arrRes = [];
        foreach ($arrAttributes as $attribute) {
            if (isset($this->_data[$attribute])) {
                $arrRes[$attribute] = $this->_data[$attribute];
            }
            else {
                $arrRes[$attribute] = null;
            }
        }
        return $arrRes;
    }

    /**
     * Public wrapper for __toArray
     *
     * @param Array<string> $arrAttributes
     */
    public function toArray(array $arrAttributes = []): array
    {
        return $this->__toArray($arrAttributes);
    }

    /**
     * Set required array elements
     *
     * @template T
     * @param Array<string, T> $arr
     * @param Array<string> $elements
     * @return Array<string, ?T>
     */
    protected function _prepareArray(array $arr, array $elements = []): array
    {
        foreach ($elements as $element) {
            if (!isset($arr[$element])) {
                $arr[$element] = null;
            }
        }
        return $arr;
    }

    /**
     * Convert object attributes to XML
     *
     * @param Array<string> $arrAttributes array of required attributes
     */
    protected function __toXml(
        array $arrAttributes = [],
        string $rootName = 'item',
        bool $addOpenTag = false,
        bool $addCdata = true
    ): string {
        $xml = '';
        if ($addOpenTag) {
            $xml.= '<?xml version="1.0" encoding="UTF-8"?>'."\n";
        }
        if (!empty($rootName)) {
            $xml.= '<'.$rootName.'>'."\n";
        }
        $xmlModel = new Varien_Simplexml_Element('<node></node>');
        $arrData = $this->toArray($arrAttributes);
        foreach ($arrData as $fieldName => $fieldValue) {
            if ($addCdata === true) {
                $fieldValue = "<![CDATA[$fieldValue]]>";
            } else {
                $fieldValue = $xmlModel->xmlentities($fieldValue);
            }
            $xml.= "<$fieldName>$fieldValue</$fieldName>"."\n";
        }
        if (!empty($rootName)) {
            $xml.= '</'.$rootName.'>'."\n";
        }
        return $xml;
    }

    /**
     * Public wrapper for __toXml
     *
     * @param Array<string> $arrAttributes
     */
    public function toXml(
        array $arrAttributes = [],
        string $rootName = 'item',
        bool $addOpenTag = false,
        bool $addCdata = true
    ): string {
        return $this->__toXml($arrAttributes, $rootName, $addOpenTag, $addCdata);
    }

    /**
     * Convert object attributes to JSON
     *
     * @param Array<string> $arrAttributes array of required attributes
     */
    protected function __toJson(array $arrAttributes = []): string
    {
        $arrData = $this->toArray($arrAttributes);
        $json = Zend_Json::encode($arrData);
        return $json;
    }

    /**
     * Public wrapper for __toJson
     */
    public function toJson(array $arrAttributes = []): string
    {
        return $this->__toJson($arrAttributes);
    }

    /**
     * Convert object attributes to string
     *
     * @param  array  $arrAttributes array of required attributes
     * @param  string $valueSeparator
     * @return string
     */
//    public function __toString(array $arrAttributes = [], $valueSeparator=',')
//    {
//        $arrData = $this->toArray($arrAttributes);
//        return implode($valueSeparator, $arrData);
//    }

    /**
     * Public wrapper for __toString
     *
     * Will use $format as an template and substitute {{key}} for attributes
     */
    public function toString(string $format = ''): string
    {
        if (empty($format)) {
            $str = implode(', ', $this->getData());
        } else {
            preg_match_all('/\{\{([a-z0-9_]+)\}\}/is', $format, $matches);
            foreach ($matches[1] as $var) {
                $format = str_replace('{{'.$var.'}}', $this->getData($var), $format);
            }
            $str = $format;
        }
        return $str;
    }

    /**
     * Set/Get attribute wrapper
     */
    public function __call(string $method, array $args): mixed
    {
        switch (substr($method, 0, 3)) {
            case 'get' :
                //Varien_Profiler::start('GETTER: '.get_class($this).'::'.$method);
                $key = $this->_underscore(substr($method,3));

                if(count($args) > 0) {
                    return $this->getData($key, $args[0]);
                }

                return $this->getData($key);

                //Varien_Profiler::stop('GETTER: '.get_class($this).'::'.$method);
                return $data;

            case 'set' :
                //Varien_Profiler::start('SETTER: '.get_class($this).'::'.$method);
                $key = $this->_underscore(substr($method,3));
                $result = $this->setData($key, isset($args[0]) ? $args[0] : null);
                //Varien_Profiler::stop('SETTER: '.get_class($this).'::'.$method);
                return $result;

            case 'uns' :
                //Varien_Profiler::start('UNS: '.get_class($this).'::'.$method);
                $key = $this->_underscore(substr($method,3));
                $result = $this->unsetData($key);
                //Varien_Profiler::stop('UNS: '.get_class($this).'::'.$method);
                return $result;

            case 'has' :
                //Varien_Profiler::start('HAS: '.get_class($this).'::'.$method);
                $key = $this->_underscore(substr($method,3));
                //Varien_Profiler::stop('HAS: '.get_class($this).'::'.$method);
                return isset($this->_data[$key]);
        }
        throw new Varien_Exception("Invalid method ".get_class($this)."::".$method);
    }

    /**
     * Attribute getter (deprecated)
     *
     * @param string $var
     * @return mixed
     */
    public function __get(string $var)
    {
        $e = new Exception(sprintf("Deprecated %s functionality", __METHOD__));

        Mage::logException($e, "varien_object", Zend_Log::WARN);

        // if(Mage::getIsDeveloperMode()) {
        //     throw $e;
        // }

        $var = $this->_underscore($var);
        return $this->getData($var);
    }

    /**
     * Attribute setter (deprecated)
     *
     * @param string $var
     * @param mixed $value
     */
    public function __set(string $var, mixed $value)
    {
        $e = new Exception(sprintf("Deprecated %s functionality", __METHOD__));

        Mage::logException($e, "varien_object", Zend_Log::WARN);

        // if(Mage::getIsDeveloperMode()) {
        //     throw $e;
        // }

        $var = $this->_underscore($var);
        $this->setData($var, $value);
    }

    /**
     * Checks whether the object is empty
     */
    public function isEmpty(): bool
    {
        if (empty($this->_data)) {
            return true;
        }
        return false;
    }

    /**
     * Converts field names for setters and geters
     *
     * $this->setMyField($value) === $this->setData('my_field', $value)
     * Uses cache to eliminate unneccessary preg_replace
     *
     * @param string $name
     * @return string
     */
    protected function _underscore(string $name): string
    {
        if (isset(self::$_underscoreCache[$name])) {
            return self::$_underscoreCache[$name];
        }
        #Varien_Profiler::start('underscore');
        $result = strtolower(preg_replace('/([A-Z])/', "_$1", lcfirst($name)));
        #Varien_Profiler::stop('underscore');
        self::$_underscoreCache[$name] = $result;
        return $result;
    }

    protected function _camelize(string $name): string
    {
        return uc_words($name, '');
    }

    /**
     * serialize object attributes
     *
     * @param Array<string> $attributes
     */
    public function serialize(
        array $attributes = [],
        string $valueSeparator = '=',
        string $fieldSeparator = ' ',
        string $quote = '"'
    ): string {
        $res  = '';
        $data = [];
        if (empty($attributes)) {
            $attributes = array_keys($this->_data);
        }

        foreach ($this->_data as $key => $value) {
            if (in_array($key, $attributes)) {
                $data[] = $key . $valueSeparator . $quote . $value . $quote;
            }
        }
        $res = implode($fieldSeparator, $data);
        return $res;
    }

    /**
     * Clears data changes status
     *
     * @return $this
     */
    public function setDataChanges(bool $value): self
    {
        $this->_hasDataChanges = $value;

        return $this;
    }

    /**
     * Present object data as string in debug mode
     */
    public function debug(mixed $data = null, array &$objects = []): array|string
    {
        if (is_null($data)) {
            $hash = spl_object_hash($this);
            if (!empty($objects[$hash])) {
                return '*** RECURSION ***';
            }
            $objects[$hash] = true;
            $data = $this->getData();
        }
        $debug = [];
        foreach ($data as $key=>$value) {
            if (is_scalar($value)) {
                $debug[$key] = $value;
            } elseif (is_array($value)) {
                $debug[$key] = $this->debug($value, $objects);
            } elseif ($value instanceof Varien_Object) {
                $debug[$key.' ('.get_class($value).')'] = $value->debug(null, $objects);
            }
        }
        return $debug;
    }

    /**
     * Implementation of ArrayAccess::offsetSet()
     *
     * @link http://www.php.net/manual/en/arrayaccess.offsetset.php
     * @param string $offset
     */
    public function offsetSet(mixed $offset, mixed $value): void
    {
        $this->_data[$offset] = $value;
    }

    /**
     * Implementation of ArrayAccess::offsetExists()
     *
     * @link http://www.php.net/manual/en/arrayaccess.offsetexists.php
     * @param string $offset
     */
    public function offsetExists(mixed $offset): bool
    {
        return isset($this->_data[$offset]);
    }

    /**
     * Implementation of ArrayAccess::offsetUnset()
     *
     * @link http://www.php.net/manual/en/arrayaccess.offsetunset.php
     * @param string $offset
     */
    public function offsetUnset(mixed $offset): void
    {
        unset($this->_data[$offset]);
    }

    /**
     * Implementation of ArrayAccess::offsetGet()
     *
     * @link http://www.php.net/manual/en/arrayaccess.offsetget.php
     * @param string $offset
     */
    public function offsetGet(mixed $offset): mixed
    {
        return isset($this->_data[$offset]) ? $this->_data[$offset] : null;
    }


    public function isDirty(?string $field = null): bool
    {
        if (empty($this->_dirty)) {
            return false;
        }
        if (is_null($field)) {
            return true;
        }
        return isset($this->_dirty[$field]);
    }

    /**
     * @return $this
     */
    public function flagDirty(?string $field = null, bool $flag = true): self
    {
        $keys = $field !== null ? [$field] : array_keys($this->_data);

        foreach($keys as $field) {
            if ($flag) {
                $this->_dirty[$field] = true;
            } else {
                unset($this->_dirty[$field]);
            }
        }

        return $this;
    }
}
