<?php

namespace Telnyx;

/**
 * Class TelnyxObject
 *
 * @package Telnyx
 */
class TelnyxObject implements \ArrayAccess, \Countable, \JsonSerializable
{
    protected $_opts;
    protected $_originalValues;
    protected $_values;
    protected $_unsavedValues;
    protected $_transientValues;
    protected $_retrieveOptions;
    protected $_lastResponse;

    /**
     * @return Util\Set Attributes that should not be sent to the API because
     *    they're not updatable (e.g. ID).
     */
    public static function getPermanentAttributes()
    {
        static $permanentAttributes = null;
        if ($permanentAttributes === null) {
            $permanentAttributes = new Util\Set([
                'id',
            ]);
        }
        return $permanentAttributes;
    }

    /**
     * Additive objects are subobjects in the API that don't have the same
     * semantics as most subobjects, which are fully replaced when they're set.
     * This is best illustrated by example. The `source` parameter sent when
     * updating a subscription is *not* additive; if we set it:
     *
     *     source[object]=card&source[number]=123
     *
     * We expect the old `source` object to have been overwritten completely. If
     * the previous source had an `address_state` key associated with it and we
     * didn't send one this time, that value of `address_state` is gone.
     *
     * By contrast, additive objects are those that will have new data added to
     * them while keeping any existing data in place. The only known case of its
     * use is for `metadata`, but it could in theory be more general. As an
     * example, say we have a `metadata` object that looks like this on the
     * server side:
     *
     *     metadata = ["old" => "old_value"]
     *
     * If we update the object with `metadata[new]=new_value`, the server side
     * object now has *both* fields:
     *
     *     metadata = ["old" => "old_value", "new" => "new_value"]
     *
     * This is okay in itself because usually users will want to treat it as
     * additive:
     *
     *     $obj->metadata["new"] = "new_value";
     *     $obj->save();
     *
     * However, in other cases, they may want to replace the entire existing
     * contents:
     *
     *     $obj->metadata = ["new" => "new_value"];
     *     $obj->save();
     *
     * This is where things get a little bit tricky because in order to clear
     * any old keys that may have existed, we actually have to send an explicit
     * empty string to the server. So the operation above would have to send
     * this form to get the intended behavior:
     *
     *     metadata[old]=&metadata[new]=new_value
     *
     * This method allows us to track which parameters are considered additive,
     * and lets us behave correctly where appropriate when serializing
     * parameters to be sent.
     *
     * @return Util\Set Set of additive parameters
     */
    public static function getAdditiveParams()
    {
        static $additiveParams = null;
        if ($additiveParams === null) {
            // Set `metadata` as additive so that when it's set directly we remember
            // to clear keys that may have been previously set by sending empty
            // values for them.
            //
            // It's possible that not every object has `metadata`, but having this
            // option set when there is no `metadata` field is not harmful.
            $additiveParams = new Util\Set([
                'metadata',
            ]);
        }
        return $additiveParams;
    }

    public function __construct($id = null, $opts = null)
    {
        list($id, $this->_retrieveOptions) = Util\Util::normalizeId($id);
        $this->_opts = Util\RequestOptions::parse($opts);
        $this->_originalValues = [];
        $this->_values = [];
        $this->_unsavedValues = new Util\Set();
        $this->_transientValues = new Util\Set();
        if ($id !== null) {
            $this->_values['id'] = $id;
        }
    }

    // Standard accessor magic methods
    public function __set($k, $v)
    {
        if (static::getPermanentAttributes()->includes($k)) {
            throw new \InvalidArgumentException(
                "Cannot set $k on this object. HINT: you can't set: " .
                join(', ', static::getPermanentAttributes()->toArray())
            );
        }

        if ($v === "") {
            throw new \InvalidArgumentException(
                'You cannot set \''.$k.'\'to an empty string. '
                .'We interpret empty strings as NULL in requests. '
                .'You may set obj->'.$k.' = NULL to delete the property'
            );
        }

        $this->_values[$k] = Util\Util::convertToTelnyxObject($v, $this->_opts);
        $this->dirtyValue($this->_values[$k]);
        $this->_unsavedValues->add($k);
    }

    public function __isset($k)
    {
        return isset($this->_values[$k]);
    }

    public function __unset($k)
    {
        unset($this->_values[$k]);
        $this->_transientValues->add($k);
        $this->_unsavedValues->discard($k);
    }

    public function &__get($k)
    {
        // function should return a reference, using $nullval to return a reference to null
        $nullval = null;
        if (!empty($this->_values) && array_key_exists($k, $this->_values)) {
            return $this->_values[$k];
        } elseif (!empty($this->_transientValues) && $this->_transientValues->includes($k)) {
            $class = get_class($this);
            $attrs = join(', ', array_keys($this->_values));
            $message = "Telnyx Notice: Undefined property of $class instance: $k. "
                    . "HINT: The $k attribute was set in the past, however. "
                    . "It was then wiped when refreshing the object "
                    . "with the result returned by Telnyx's API, "
                    . "probably as a result of a save(). The attributes currently "
                    . "available on this object are: $attrs";
            Telnyx::getLogger()->error($message);
            return $nullval;
        } else {
            $class = get_class($this);
            Telnyx::getLogger()->error("Telnyx Notice: Undefined property of $class instance: $k");
            return $nullval;
        }
    }

    // Magic method for var_dump output. Only works with PHP >= 5.6
    public function __debugInfo()
    {
        return $this->_values;
    }

    // ArrayAccess methods
    public function offsetSet($k, $v)
    {
        $this->$k = $v;
    }

    public function offsetExists($k)
    {
        return array_key_exists($k, $this->_values);
    }

    public function offsetUnset($k)
    {
        unset($this->$k);
    }

    public function offsetGet($k)
    {
        return array_key_exists($k, $this->_values) ? $this->_values[$k] : null;
    }

    // Countable method
    public function count()
    {
        return count($this->_values);
    }

    public function keys()
    {
        return array_keys($this->_values);
    }

    public function values()
    {
        return array_values($this->_values);
    }

    /**
     * This unfortunately needs to be public to be used in Util\Util
     *
     * @param array $values
     * @param null|string|array|Util\RequestOptions $opts
     *
     * @return static The object constructed from the given values.
     */
    public static function constructFrom($values, $opts = null)
    {
        $obj = new static(isset($values['id']) ? $values['id'] : null);
        $obj->refreshFrom($values, $opts);
        return $obj;
    }

    /**
     * Refreshes this object using the provided values.
     *
     * @param array $values
     * @param null|string|array|Util\RequestOptions $opts
     * @param boolean $partial Defaults to false.
     */
    public function refreshFrom($values, $opts, $partial = false)
    {
        $this->_opts = Util\RequestOptions::parse($opts);

        $this->_originalValues = self::deepCopy($values);

        if ($values instanceof TelnyxObject) {
            $values = $values->__toArray(true);
        }

        // Wipe old state before setting new.  This is useful for e.g. updating a
        // customer, where there is no persistent card parameter.  Mark those values
        // which don't persist as transient
        if ($partial) {
            $removed = new Util\Set();
        } else {
            $removed = new Util\Set(array_diff(array_keys($this->_values), array_keys($values)));
        }

        foreach ($removed->toArray() as $k) {
            unset($this->$k);
        }

        $this->updateAttributes($values, $opts, false);
        foreach ($values as $k => $v) {
            $this->_transientValues->discard($k);
            $this->_unsavedValues->discard($k);
        }
    }

    /**
     * Mass assigns attributes on the model.
     *
     * @param array $values
     * @param null|string|array|Util\RequestOptions $opts
     * @param boolean $dirty Defaults to true.
     */
    public function updateAttributes($values, $opts = null, $dirty = true)
    {
        foreach ($values as $k => $v) {
            // Special-case metadata to always be cast as a TelnyxObject
            // This is necessary in case metadata is empty, as PHP arrays do
            // not differentiate between lists and hashes, and we consider
            // empty arrays to be lists.
            if (($k === "metadata") && (is_array($v))) {
                $this->_values[$k] = TelnyxObject::constructFrom($v, $opts);
            } else {
                $this->_values[$k] = Util\Util::convertToTelnyxObject($v, $opts);
            }
            if ($dirty) {
                $this->dirtyValue($this->_values[$k]);
            }
            $this->_unsavedValues->add($k);
        }
    }

    /**
     * @return array A recursive mapping of attributes to values for this object,
     *    including the proper value for deleted attributes.
     */
    public function serializeParameters($force = false)
    {
        $updateParams = [];

        foreach ($this->_values as $k => $v) {
            // There are a few reasons that we may want to add in a parameter for
            // update:
            //
            //   1. The `$force` option has been set.
            //   2. We know that it was modified.
            //   3. Its value is a TelnyxObject. A TelnyxObject may contain modified
            //      values within in that its parent TelnyxObject doesn't know about.
            //
            $original = array_key_exists($k, $this->_originalValues) ? $this->_originalValues[$k] : null;
            $unsaved = $this->_unsavedValues->includes($k);
            if ($force || $unsaved || $v instanceof TelnyxObject) {
                $updateParams[$k] = $this->serializeParamsValue(
                    $this->_values[$k],
                    $original,
                    $unsaved,
                    $force,
                    $k
                );
            }
        }

        // a `null` that makes it out of `serializeParamsValue` signals an empty
        // value that we shouldn't appear in the serialized form of the object
        $updateParams = array_filter(
            $updateParams,
            function ($v) {
                return $v !== null;
            }
        );

        return $updateParams;
    }


    public function serializeParamsValue($value, $original, $unsaved, $force, $key = null)
    {
        // The logic here is that essentially any object embedded in another
        // object that had a `type` is actually an API resource of a different
        // type that's been included in the response. These other resources must
        // be updated from their proper endpoints, and therefore they are not
        // included when serializing even if they've been modified.
        //
        // There are _some_ known exceptions though.
        //
        // For example, if the value is unsaved (meaning the user has set it), and
        // it looks like the API resource is persisted with an ID, then we include
        // the object so that parameters are serialized with a reference to its
        // ID.
        //
        // Another example is that on save API calls it's sometimes desirable to
        // update a customer's default source by setting a new card (or other)
        // object with `->source=` and then saving the customer. The
        // `saveWithParent` flag to override the default behavior allows us to
        // handle these exceptions.
        //
        // We throw an error if a property was set explicitly but we can't do
        // anything with it because the integration is probably not working as the
        // user intended it to.
        if ($value === null) {
            return "";
        } elseif (($value instanceof APIResource) && (!$value->saveWithParent)) {
            if (!$unsaved) {
                return null;
            } elseif (isset($value->id)) {
                return $value;
            } else {
                throw new \InvalidArgumentException(
                    "Cannot save property `$key` containing an API resource of type " .
                    get_class($value) . ". It doesn't appear to be persisted and is " .
                    "not marked as `saveWithParent`."
                );
            }
        } elseif (is_array($value)) {
            if (Util\Util::isList($value)) {
                // Sequential array, i.e. a list
                $update = [];
                foreach ($value as $v) {
                    array_push($update, $this->serializeParamsValue($v, null, true, $force));
                }
                // This prevents an array that's unchanged from being resent.
                if ($update !== $this->serializeParamsValue($original, null, true, $force, $key)) {
                    return $update;
                }
            } else {
                // Associative array, i.e. a map
                return Util\Util::convertToTelnyxObject($value, $this->_opts)->serializeParameters();
            }
        } elseif ($value instanceof TelnyxObject) {
            $update = $value->serializeParameters($force);
            if ($original && $unsaved && $key && static::getAdditiveParams()->includes($key)) {
                $update = array_merge(self::emptyValues($original), $update);
            }
            return $update;
        } else {
            return $value;
        }
    }

    public function jsonSerialize()
    {
        return $this->__toArray(true);
    }

    public function __toJSON()
    {
        return json_encode($this->__toArray(true), JSON_PRETTY_PRINT);
    }

    public function __toString()
    {
        $class = get_class($this);
        return $class . ' JSON: ' . $this->__toJSON();
    }

    public function __toArray($recursive = false)
    {
        if ($recursive) {
            return Util\Util::convertTelnyxObjectToArray($this->_values);
        } else {
            return $this->_values;
        }
    }

    /**
     * Sets all keys within the TelnyxObject as unsaved so that they will be
     * included with an update when `serializeParameters` is called. This
     * method is also recursive, so any TelnyxObjects contained as values or
     * which are values in a tenant array are also marked as dirty.
     */
    public function dirty()
    {
        $this->_unsavedValues = new Util\Set(array_keys($this->_values));
        foreach ($this->_values as $k => $v) {
            $this->dirtyValue($v);
        }
    }

    protected function dirtyValue($value)
    {
        if (is_array($value)) {
            foreach ($value as $v) {
                $this->dirtyValue($v);
            }
        } elseif ($value instanceof TelnyxObject) {
            $value->dirty();
        }
    }

    /**
     * Produces a deep copy of the given object including support for arrays
     * and TelnyxObjects.
     */
    protected static function deepCopy($obj)
    {
        if (is_array($obj)) {
            $copy = [];
            foreach ($obj as $k => $v) {
                $copy[$k] = self::deepCopy($v);
            }
            return $copy;
        } elseif ($obj instanceof TelnyxObject) {
            return $obj::constructFrom(
                self::deepCopy($obj->_values),
                clone $obj->_opts
            );
        } else {
            return $obj;
        }
    }

    /**
     * Returns a hash of empty values for all the values that are in the given
     * TelnyxObject.
     */
    public static function emptyValues($obj)
    {
        if (is_array($obj)) {
            $values = $obj;
        } elseif ($obj instanceof TelnyxObject) {
            $values = $obj->_values;
        } else {
            throw new \InvalidArgumentException(
                "empty_values got got unexpected object type: " . get_class($obj)
            );
        }
        $update = array_fill_keys(array_keys($values), "");
        return $update;
    }

    /**
     * @return object The last response from the Telnyx API
     */
    public function getLastResponse()
    {
        return $this->_lastResponse;
    }

    /**
     * Sets the last response from the Telnyx API
     *
     * @param ApiResponse $resp
     * @return void
     */
    public function setLastResponse($resp)
    {
        $this->_lastResponse = $resp;
    }

    /**
     * Indicates whether or not the resource has been deleted on the server.
     * Note that some, but not all, resources can indicate whether they have
     * been deleted.
     *
     * @return bool Whether the resource is deleted.
     */
    public function isDeleted()
    {
        return isset($this->_values['deleted']) ? $this->_values['deleted'] : false;
    }
}
