<?php

/**
 * RouterOS API client implementation.

 *
 * RouterOS is the flag product of the company MikroTik and is a powerful router software. One of its many abilities is to allow control over it via an API. This package provides a client for that API, in turn allowing you to use PHP to control RouterOS hosts.
 *
 * PHP version 5
 *
 * @category  Net
 * @package   PEAR2_Net_RouterOS
 * @author    Vasil Rangelov <boen.robot@gmail.com>
 * @copyright 2011 Vasil Rangelov
 * @license   http://www.gnu.org/copyleft/lesser.html LGPL License 2.1
 * @version   1.0.0b6
 * @link      http://pear2.php.net/PEAR2_Net_RouterOS
 */
/**
 * The namespace declaration.
 */
namespace PEAR2\Net\RouterOS;

/**
 * Returned from {@link Util::getCurrentTime()}.
 */
use DateTime;

/**
 * Used at {@link Util::getCurrentTime()} to get the proper time.
 */
use DateTimeZone;

/**
 * Implemented by this class.
 */
use Countable;

/**
 * Used to detect streams in various methods of this class.
 */
use PEAR2\Net\Transmitter\Stream;

/**
 * Used to catch a DateTime exception at {@link Util::getCurrentTime()}.
 */
use Exception as E;

/**
 * Utility class.
 *
 * Abstracts away frequently used functionality (particularly CRUD operations)
 * in convenient to use methods by wrapping around a connection.
 *
 * @category Net
 * @package  PEAR2_Net_RouterOS
 * @author   Vasil Rangelov <boen.robot@gmail.com>
 * @license  http://www.gnu.org/copyleft/lesser.html LGPL License 2.1
 * @link     http://pear2.php.net/PEAR2_Net_RouterOS
 */
class Util implements Countable
{
    /**
     * The connection to wrap around.
     *
     * @var Client
     */
    protected $client;

    /**
     * The current menu.
     *
     * Note that the root menu (only) uses an empty string.
     * This is done to enable commands executed at it without special casing it
     * at all commands.
     * Instead, only {@link static::setMenu()} is special cased.
     *
     * @var string
     */
    protected $menu = '';

    /**
     * An array with the numbers of items in the current menu.
     *
     * Numbers as keys, and the corresponding IDs as values.
     * NULL when the cache needs regenerating.
     *
     * @var array<int,string>|null
     */
    protected $idCache = null;

    /**
     * Creates a new Util instance.
     *
     * Wraps around a connection to provide convenience methods.
     *
     * @param Client $client The connection to wrap around.
     */
    public function __construct(Client $client)
    {
        $this->client = $client;
    }

    /**
     * Gets the current menu.
     *
     * @return string The absolute path to current menu, using API syntax.
     */
    public function getMenu()
    {
        return '' === $this->menu ? '/' : $this->menu;
    }

    /**
     * Sets the current menu.
     *
     * Sets the current menu.
     *
     * @param string $newMenu The menu to change to. Can be specified with API
     *     or CLI syntax and can be either absolute or relative. If relative,
     *     it's relative to the current menu, which by default is the root.
     *
     * @return $this The object itself. If an empty string is given for
     *     a new menu, no change is performed,
     *     but the ID cache is cleared anyway.
     *
     * @see static::clearIdCache()
     */
    public function setMenu($newMenu)
    {
        $newMenu = (string)$newMenu;
        if ('' !== $newMenu) {
            $menuRequest = new Request('/menu');
            if ('/' === $newMenu) {
                $this->menu = '';
            } elseif ('/' === $newMenu[0]) {
                $this->menu = $menuRequest->setCommand($newMenu)->getCommand();
            } else {
                $newMenu = (string)substr(
                    $menuRequest->setCommand(
                        '/' .
                        str_replace('/', ' ', (string)substr($this->menu, 1)) .
                        ' ' .
                        str_replace('/', ' ', $newMenu)
                        . ' ?'
                    )->getCommand(),
                    1,
                    -2/*strlen('/?')*/
                );
                if ('' !== $newMenu) {
                    $this->menu = '/' . $newMenu;
                } else {
                    $this->menu = '';
                }
            }
        }
        $this->clearIdCache();
        return $this;
    }

    /**
     * Creates a Request object.
     *
     * Creates a {@link Request} object, with a command that's at the
     * current menu. The request can then be sent using {@link Client}.
     *
     * @param string      $command The command of the request, not including
     *     the menu. The request will have that command at the current menu.
     * @param array       $args    Arguments of the request.
     *     Each array key is the name of the argument, and each array value is
     *     the value of the argument to be passed.
     *     Arguments without a value (i.e. empty arguments) can also be
     *     specified using a numeric key, and the name of the argument as the
     *     array value.
     * @param Query|null  $query   The {@link Query} of the request.
     * @param string|null $tag     The tag of the request.
     *
     * @return Request The {@link Request} object.
     *
     * @throws NotSupportedException On an attempt to call a command in a
     *     different menu using API syntax.
     * @throws InvalidArgumentException On an attempt to call a command in a
     *     different menu using CLI syntax.
     */
    public function newRequest(
        $command,
        array $args = array(),
        Query $query = null,
        $tag = null
    ) {
        if (false !== strpos($command, '/')) {
            throw new NotSupportedException(
                'Command tried to go to a different menu',
                NotSupportedException::CODE_MENU_MISMATCH,
                null,
                $command
            );
        }
        $request = new Request('/menu', $query, $tag);
        $request->setCommand("{$this->menu}/{$command}");
        foreach ($args as $name => $value) {
            if (is_int($name)) {
                $request->setArgument($value);
            } else {
                $request->setArgument($name, $value);
            }
        }
        return $request;
    }

    /**
     * Executes a RouterOS script.
     *
     * Executes a RouterOS script, written as a string or a stream.
     * Note that in cases of errors, the line numbers will be off, because the
     * script is executed at the current menu as context, with the specified
     * variables pre declared. This is achieved by prepending 1+count($params)
     * lines before your actual script.
     *
     * @param string|resource         $source The source of the script,
     *     as a string or stream. If a stream is provided, reading starts from
     *     the current position to the end of the stream, and the pointer stays
     *     at the end after reading is done.
     * @param array<string|int,mixed> $params An array of parameters to make
     *     available in the script as local variables.
     *     Variable names are array keys, and variable values are array values.
     *     Array values are automatically processed with
     *     {@link static::escapeValue()}. Streams are also supported, and are
     *     processed in chunks, each with
     *     {@link static::escapeString()} with all bytes being escaped.
     *     Processing starts from the current position to the end of the stream,
     *     and the stream's pointer is left untouched after the reading is done.
     *     Variables with a value of type "nothing" can be declared with a
     *     numeric array key and the variable name as the array value
     *     (that is casted to a string).
     *     Note that the script's (generated) name is always added as the
     *     variable "_", which will be inadvertently lost if you overwrite it
     *     from here.
     * @param string|null             $policy Allows you to specify a policy the
     *     script must follow. Has the same format as in terminal.
     *     If left NULL, the script has no restrictions beyond those imposed by
     *     the username.
     * @param string|null             $name   The script is executed after being
     *     saved in "/system script" and is removed after execution.
     *     If this argument is left NULL, a random string,
     *     prefixed with the computer's name, is generated and used
     *     as the script's name.
     *     To eliminate any possibility of name clashes,
     *     you can specify your own name instead.
     *
     * @return ResponseCollection The responses of all requests involved, i.e.
     *     the add, the run and the remove.
     *
     * @throws RouterErrorException When there is an error in any step of the
     *     way. The reponses include all successful commands prior to the error
     *     as well. If the error occurs during the run, there will also be a
     *     remove attempt, and the results will include its results as well.
     */
    public function exec(
        $source,
        array $params = array(),
        $policy = null,
        $name = null
    ) {
        if (null === $name) {
            $name = uniqid(gethostname(), true);
        }

        $request = new Request('/system/script/add');
        $request->setArgument('name', $name);
        $request->setArgument('policy', $policy);

        $params += array('_' => $name);

        $finalSource = fopen('php://temp', 'r+b');
        fwrite(
            $finalSource,
            '/' . str_replace('/', ' ', substr($this->menu, 1)). "\n"
        );
        Script::append($finalSource, $source, $params);
        fwrite($finalSource, "\n");
        rewind($finalSource);

        $request->setArgument('source', $finalSource);
        $addResult = $this->client->sendSync($request);

        if (count($addResult->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when trying to add script',
                RouterErrorException::CODE_SCRIPT_ADD_ERROR,
                null,
                $addResult
            );
        }

        $request = new Request('/system/script/run');
        $request->setArgument('number', $name);
        $runResult = $this->client->sendSync($request);
        $request = new Request('/system/script/remove');
        $request->setArgument('numbers', $name);
        $removeResult = $this->client->sendSync($request);

        $results = new ResponseCollection(
            array_merge(
                $addResult->toArray(),
                $runResult->toArray(),
                $removeResult->toArray()
            )
        );

        if (count($runResult->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when running script',
                RouterErrorException::CODE_SCRIPT_RUN_ERROR,
                null,
                $results
            );
        }
        if (count($removeResult->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when removing script',
                RouterErrorException::CODE_SCRIPT_REMOVE_ERROR,
                null,
                $results
            );
        }

        return $results;
    }

    /**
     * Clears the ID cache.
     *
     * Normally, the ID cache improves performance when targeting items by a
     * number. If you're using both Util's methods and other means (e.g.
     * {@link Client} or {@link Util::exec()}) to add/move/remove items, the
     * cache may end up being out of date. By calling this method right before
     * targeting an item with a number, you can ensure number accuracy.
     *
     * Note that Util's {@link static::move()} and {@link static::remove()}
     * methods automatically clear the cache before returning, while
     * {@link static::add()} adds the new item's ID to the cache as the next
     * number. A change in the menu also clears the cache.
     *
     * Note also that the cache is being rebuilt unconditionally every time you
     * use {@link static::find()} with a callback.
     *
     * @return $this The Util object itself.
     */
    public function clearIdCache()
    {
        $this->idCache = null;
        return $this;
    }

    /**
     * Gets the current time on the router.
     *
     * Gets the current time on the router, regardless of the current menu.
     *
     * If the timezone is one known to both RouterOS and PHP, it will be used
     * as the timezone identifier. Otherwise (e.g. "manual"), the current GMT
     * offset will be used as a timezone, without any DST awareness.
     *
     * @return DateTime The current time of the router, as a DateTime object.
     */
    public function getCurrentTime()
    {
        $clock = $this->client->sendSync(
            new Request(
                '/system/clock/print
                .proplist=date,time,time-zone-name,gmt-offset'
            )
        )->current();
        $clockParts = array();
        foreach (array(
            'date',
            'time',
            'time-zone-name',
            'gmt-offset'
        ) as $clockPart) {
            $clockParts[$clockPart] = $clock->getProperty($clockPart);
            if (Stream::isStream($clockParts[$clockPart])) {
                $clockParts[$clockPart] = stream_get_contents(
                    $clockParts[$clockPart]
                );
            }
        }
        $datetime = ucfirst(strtolower($clockParts['date'])) . ' ' .
            $clockParts['time'];
        try {
            $result = DateTime::createFromFormat(
                'M/j/Y H:i:s',
                $datetime,
                new DateTimeZone($clockParts['time-zone-name'])
            );
        } catch (E $e) {
            $result = DateTime::createFromFormat(
                'M/j/Y H:i:s P',
                $datetime . ' ' . $clockParts['gmt-offset'],
                new DateTimeZone('UTC')
            );
        }
        return $result;
    }

    /**
     * Finds the IDs of items at the current menu.
     *
     * Finds the IDs of items based on specified criteria, and returns them as
     * a comma separated string, ready for insertion at a "numbers" argument.
     *
     * Accepts zero or more criteria as arguments. If zero arguments are
     * specified, returns all items' IDs. The value of each criteria can be a
     * number (just as in Winbox), a literal ID to be included, a {@link Query}
     * object, or a callback. If a callback is specified, it is called for each
     * item, with the item as an argument. If it returns a true value, the
     * item's ID is included in the result. Every other value is casted to a
     * string. A string is treated as a comma separated values of IDs, numbers
     * or callback names. Non-existent callback names are instead placed in the
     * result, which may be useful in menus that accept identifiers other than
     * IDs, but note that it can cause errors on other menus.
     *
     * @return string A comma separated list of all items matching the
     *     specified criteria.
     */
    public function find()
    {
        if (func_num_args() === 0) {
            if (null === $this->idCache) {
                $ret = $this->client->sendSync(
                    new Request($this->menu . '/find')
                )->getProperty('ret');
                if (null === $ret) {
                    $this->idCache = array();
                    return '';
                } elseif (!is_string($ret)) {
                    $ret = stream_get_contents($ret);
                }

                $idCache = str_replace(
                    ';',
                    ',',
                    strtolower($ret)
                );
                $this->idCache = explode(',', $idCache);
                return $idCache;
            }
            return implode(',', $this->idCache);
        }
        $idList = '';
        foreach (func_get_args() as $criteria) {
            if ($criteria instanceof Query) {
                foreach ($this->client->sendSync(
                    new Request($this->menu . '/print .proplist=.id', $criteria)
                )->getAllOfType(Response::TYPE_DATA) as $response) {
                    $newId = $response->getProperty('.id');
                    $idList .= strtolower(
                        is_string($newId)
                        ? $newId
                        : stream_get_contents($newId) . ','
                    );
                }
            } elseif (is_callable($criteria)) {
                $idCache = array();
                foreach ($this->client->sendSync(
                    new Request($this->menu . '/print')
                )->getAllOfType(Response::TYPE_DATA) as $response) {
                    $newId = $response->getProperty('.id');
                    $newId = strtolower(
                        is_string($newId)
                        ? $newId
                        : stream_get_contents($newId)
                    );
                    if ($criteria($response)) {
                        $idList .= $newId . ',';
                    }
                    $idCache[] = $newId;
                }
                $this->idCache = $idCache;
            } else {
                $this->find();
                if (is_int($criteria)) {
                    if (isset($this->idCache[$criteria])) {
                        $idList = $this->idCache[$criteria] . ',';
                    }
                } else {
                    $criteria = (string)$criteria;
                    if ($criteria === (string)(int)$criteria) {
                        if (isset($this->idCache[(int)$criteria])) {
                            $idList .= $this->idCache[(int)$criteria] . ',';
                        }
                    } elseif (false === strpos($criteria, ',')) {
                        $idList .= $criteria . ',';
                    } else {
                        $criteriaArr = explode(',', $criteria);
                        for ($i = count($criteriaArr) - 1; $i >= 0; --$i) {
                            if ('' === $criteriaArr[$i]) {
                                unset($criteriaArr[$i]);
                            } elseif ('*' === $criteriaArr[$i][0]) {
                                $idList .= $criteriaArr[$i] . ',';
                                unset($criteriaArr[$i]);
                            }
                        }
                        if (!empty($criteriaArr)) {
                            $idList .= call_user_func_array(
                                array($this, 'find'),
                                $criteriaArr
                            ) . ',';
                        }
                    }
                }
            }
        }
        return rtrim($idList, ',');
    }

    /**
     * Gets a value of a specified item at the current menu.
     *
     * @param int|string|null|Query $number    A number identifying the target
     *     item. Can also be an ID or (in some menus) name. For menus where
     *     there are no items (e.g. "/system identity"), you can specify NULL.
     *     You can also specify a {@link Query}, in which case the first match
     *     will be considered the target item.
     * @param string|resource|null  $valueName The name of the value to get.
     *     If omitted, or set to NULL, gets all properties of the target item.
     *
     * @return string|resource|null|array The value of the specified
     *     property as a string or as new PHP temp stream if the underlying
     *     {@link Client::isStreamingResponses()} is set to TRUE.
     *     If the property is not set, NULL will be returned.
     *     If $valueName is NULL, returns all properties as an array, where
     *     the result is parsed with {@link Script::parseValueToArray()}.
     *
     * @throws RouterErrorException When the router returns an error response
     *     (e.g. no such item, invalid property, etc.).
     */
    public function get($number, $valueName = null)
    {
        if ($number instanceof Query) {
            $number = explode(',', $this->find($number));
            $number = $number[0];
        } elseif (is_int($number) || ((string)$number === (string)(int)$number)) {
            $this->find();
            if (isset($this->idCache[(int)$number])) {
                $number = $this->idCache[(int)$number];
            } else {
                throw new RouterErrorException(
                    'Unable to resolve number from ID cache (no such item maybe)',
                    RouterErrorException::CODE_CACHE_ERROR
                );
            }
        }

        $request = new Request($this->menu . '/get');
        $request->setArgument('number', $number);
        $request->setArgument('value-name', $valueName);
        $responses = $this->client->sendSync($request);
        if (Response::TYPE_ERROR === $responses->getType()) {
            throw new RouterErrorException(
                'Error getting property',
                RouterErrorException::CODE_GET_ERROR,
                null,
                $responses
            );
        }

        $result = $responses->getProperty('ret');
        if (Stream::isStream($result)) {
            $result = stream_get_contents($result);
        }
        if (null === $valueName) {
            // @codeCoverageIgnoreStart
            //Some earlier RouterOS versions use "," instead of ";" as separator
            //Newer versions can't possibly enter this condition
            if (false === strpos($result, ';')
                && preg_match('/^([^=,]+\=[^=,]*)(?:\,(?1))+$/', $result)
            ) {
                $result = str_replace(',', ';', $result);
            }
            // @codeCoverageIgnoreEnd
            return Script::parseValueToArray('{' . $result . '}');
        }
        return $result;
    }

    /**
     * Enables all items at the current menu matching certain criteria.
     *
     * Zero or more arguments can be specified, each being a criteria.
     * If zero arguments are specified, enables all items.
     * See {@link static::find()} for a description of what criteria are
     * accepted.
     *
     * @return ResponseCollection Returns the response collection, allowing you
     *     to inspect the output. Current RouterOS versions don't return
     *     anything useful, but if future ones do, you can read it right away.
     *
     * @throws RouterErrorException When the router returns one or more errors.
     */
    public function enable()
    {
        $responses = $this->doBulk('enable', func_get_args());
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when enabling items',
                RouterErrorException::CODE_ENABLE_ERROR,
                null,
                $responses
            );
        }
        return $responses;
    }

    /**
     * Disables all items at the current menu matching certain criteria.
     *
     * Zero or more arguments can be specified, each being a criteria.
     * If zero arguments are specified, disables all items.
     * See {@link static::find()} for a description of what criteria are
     * accepted.
     *
     * @return ResponseCollection Returns the response collection, allowing you
     *     to inspect the output. Current RouterOS versions don't return
     *     anything useful, but if future ones do, you can read it right away.
     *
     * @throws RouterErrorException When the router returns one or more errors.
     */
    public function disable()
    {
        $responses = $this->doBulk('disable', func_get_args());
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when disabling items',
                RouterErrorException::CODE_DISABLE_ERROR,
                null,
                $responses
            );
        }
        return $responses;
    }

    /**
     * Removes all items at the current menu matching certain criteria.
     *
     * Zero or more arguments can be specified, each being a criteria.
     * If zero arguments are specified, removes all items.
     * See {@link static::find()} for a description of what criteria are
     * accepted.
     *
     * @return ResponseCollection Returns the response collection, allowing you
     *     to inspect the output. Current RouterOS versions don't return
     *     anything useful, but if future ones do, you can read it right away.
     *
     * @throws RouterErrorException When the router returns one or more errors.
     */
    public function remove()
    {
        $responses = $this->doBulk('remove', func_get_args());
        $this->clearIdCache();
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when removing items',
                RouterErrorException::CODE_REMOVE_ERROR,
                null,
                $responses
            );
        }
        return $responses;
    }

    /**
     * Comments items.
     *
     * Sets new comments on all items at the current menu
     * which match certain criteria, using the "comment" command.
     *
     * Note that not all menus have a "comment" command. Most notably, those are
     * menus without items in them (e.g. "/system identity"), and menus with
     * fixed items (e.g. "/ip service").
     *
     * @param mixed           $numbers Targeted items. Can be any criteria
     *     accepted by {@link static::find()}.
     * @param string|resource $comment The new comment to set on the item as a
     *     string or a seekable stream.
     *     If a seekable stream is provided, it is sent from its current
     *     position to its end, and the pointer is seeked back to its current
     *     position after sending.
     *     Non seekable streams, as well as all other types, are casted to a
     *     string.
     *
     * @return ResponseCollection Returns the response collection, allowing you
     *     to inspect the output. Current RouterOS versions don't return
     *     anything useful, but if future ones do, you can read it right away.
     *
     * @throws RouterErrorException When the router returns one or more errors.
     */
    public function comment($numbers, $comment)
    {
        $commentRequest = new Request($this->menu . '/comment');
        $commentRequest->setArgument('comment', $comment);
        $commentRequest->setArgument('numbers', $this->find($numbers));
        $responses = $this->client->sendSync($commentRequest);
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when commenting items',
                RouterErrorException::CODE_COMMENT_ERROR,
                null,
                $responses
            );
        }
        return $responses;
    }

    /**
     * Sets new values.
     *
     * Sets new values on certain properties on all items at the current menu
     * which match certain criteria.
     *
     * @param mixed                                           $numbers   Items
     *     to be modified.
     *     Can be any criteria accepted by {@link static::find()} or NULL
     *     in case the menu is one without items (e.g. "/system identity").
     * @param array<string,string|resource>|array<int,string> $newValues An
     *     array with the names of each property to set as an array key, and the
     *     new value as an array value.
     *     Flags (properties with a value "true" that is interpreted as
     *     equivalent of "yes" from CLI) can also be specified with a numeric
     *     index as the array key, and the name of the flag as the array value.
     *
     * @return ResponseCollection Returns the response collection, allowing you
     *     to inspect the output. Current RouterOS versions don't return
     *     anything useful, but if future ones do, you can read it right away.
     *
     * @throws RouterErrorException When the router returns one or more errors.
     */
    public function set($numbers, array $newValues)
    {
        $setRequest = new Request($this->menu . '/set');
        foreach ($newValues as $name => $value) {
            if (is_int($name)) {
                $setRequest->setArgument($value, 'true');
            } else {
                $setRequest->setArgument($name, $value);
            }
        }
        if (null !== $numbers) {
            $setRequest->setArgument('numbers', $this->find($numbers));
        }
        $responses = $this->client->sendSync($setRequest);
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when setting items',
                RouterErrorException::CODE_SET_ERROR,
                null,
                $responses
            );
        }
        return $responses;
    }

    /**
     * Alias of {@link static::set()}
     *
     * @param mixed                $numbers   Items to be modified.
     *     Can be any criteria accepted by {@link static::find()} or NULL
     *     in case the menu is one without items (e.g. "/system identity").
     * @param string               $valueName Name of property to be modified.
     * @param string|resource|null $newValue  The new value to set.
     *     If set to NULL, the property is unset.
     *
     * @return ResponseCollection Returns the response collection, allowing you
     *     to inspect the output. Current RouterOS versions don't return
     *     anything useful, but if future ones do, you can read it right away.
     *
     * @throws RouterErrorException When the router returns one or more errors.
     */
    public function edit($numbers, $valueName, $newValue)
    {
        return null === $newValue
            ? $this->unsetValue($numbers, $valueName)
            : $this->set($numbers, array($valueName => $newValue));
    }

    /**
     * Unsets a value of a specified item at the current menu.
     *
     * Equivalent of scripting's "unset" command. The "Value" part in the method
     * name is added because "unset" is a language construct, and thus a
     * reserved word.
     *
     * @param mixed  $numbers   Targeted items. Can be any criteria accepted
     *     by {@link static::find()}.
     * @param string $valueName The name of the value you want to unset.
     *
     * @return ResponseCollection Returns the response collection, allowing you
     *     to inspect the output. Current RouterOS versions don't return
     *     anything useful, but if future ones do, you can read it right away.
     *
     * @throws RouterErrorException When the router returns one or more errors.
     */
    public function unsetValue($numbers, $valueName)
    {
        $unsetRequest = new Request($this->menu . '/unset');
        $responses = $this->client->sendSync(
            $unsetRequest->setArgument('numbers', $this->find($numbers))
                ->setArgument('value-name', $valueName)
        );
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when unsetting value of items',
                RouterErrorException::CODE_UNSET_ERROR,
                null,
                $responses
            );
        }
        return $responses;
    }

    /**
     * Adds a new item at the current menu.
     *
     * @param array<string,string|resource>|array<int,string> $values Accepts
     *     one or more items to add to the current menu.
     *     The data about each item is specified as an array with the names of
     *     each property as an array key, and the value as an array value.
     *     Flags (properties with a value "true" that is interpreted as
     *     equivalent of "yes" from CLI) can also be specified with a numeric
     *     index as the array key, and the name of the flag as the array value.
     * @param array<string,string|resource>|array<int,string> $...    Additional
     *     items.
     *
     * @return string A comma separated list of the new items' IDs.
     *
     * @throws RouterErrorException When one or more items were not succesfully
     *     added. Note that the response collection will include all replies of
     *     all add commands, including the successful ones, in order.
     */
    public function add(array $values)
    {
        $addRequest = new Request($this->menu . '/add');
        $hasErrors = false;
        $results = array();
        foreach (func_get_args() as $values) {
            if (!is_array($values)) {
                continue;
            }
            foreach ($values as $name => $value) {
                if (is_int($name)) {
                    $addRequest->setArgument($value, 'true');
                } else {
                    $addRequest->setArgument($name, $value);
                }
            }
            $result = $this->client->sendSync($addRequest);
            if (count($result->getAllOfType(Response::TYPE_ERROR)) > 0) {
                $hasErrors = true;
            }
            $results = array_merge($results, $result->toArray());
            $addRequest->removeAllArguments();
        }

        $this->clearIdCache();
        if ($hasErrors) {
            throw new RouterErrorException(
                'Router returned error when adding items',
                RouterErrorException::CODE_ADD_ERROR,
                null,
                new ResponseCollection($results)
            );
        }
        $results = new ResponseCollection($results);
        $idList = '';
        foreach ($results->getAllOfType(Response::TYPE_FINAL) as $final) {
            $idList .= ',' . strtolower($final->getProperty('ret'));
        }
        return substr($idList, 1);
    }

    /**
     * Moves items at the current menu before a certain other item.
     *
     * Moves items before a certain other item. Note that the "move"
     * command is not available on all menus. As a rule of thumb, if the order
     * of items in a menu is irrelevant to their interpretation, there won't
     * be a move command on that menu. If in doubt, check from a terminal.
     *
     * @param mixed $numbers     Targeted items. Can be any criteria accepted
     *     by {@link static::find()}.
     * @param mixed $destination Item before which the targeted items will be
     *     moved to. Can be any criteria accepted by {@link static::find()}.
     *     If multiple items match the criteria, the targeted items will move
     *     above the first match.
     *     If NULL is given (or this argument is omitted), the targeted items
     *     will be moved to the bottom of the menu.
     *
     * @return ResponseCollection Returns the response collection, allowing you
     *     to inspect the output. Current RouterOS versions don't return
     *     anything useful, but if future ones do, you can read it right away.
     *
     * @throws RouterErrorException When the router returns one or more errors.
     */
    public function move($numbers, $destination = null)
    {
        $moveRequest = new Request($this->menu . '/move');
        $moveRequest->setArgument('numbers', $this->find($numbers));
        if (null !== $destination) {
            $destination = $this->find($destination);
            if (false !== strpos($destination, ',')) {
                $destination = strstr($destination, ',', true);
            }
            $moveRequest->setArgument('destination', $destination);
        }
        $this->clearIdCache();
        $responses = $this->client->sendSync($moveRequest);
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when moving items',
                RouterErrorException::CODE_MOVE_ERROR,
                null,
                $responses
            );
        }
        return $responses;
    }

    /**
     * Counts items at the current menu.
     *
     * Counts items at the current menu. This executes a dedicated command
     * ("print" with a "count-only" argument) on RouterOS, which is why only
     * queries are allowed as a criteria, in contrast with
     * {@link static::find()}, where numbers and callbacks are allowed also.
     *
     * @param Query|null           $query A query to filter items by.
     *     Without it, all items are included in the count.
     * @param string|resource|null $from  A comma separated list of item IDs.
     *     Any items in the set that still exist at the time of couting
     *     are included in the final tally. Note that the $query filters this
     *     set further (i.e. the item must be in the list AND match the $query).
     *     Leaving the value to NULL means all matching items at the current
     *     menu are included in the count.
     *
     * @return int The number of items, or -1 on failure (e.g. if the
     *     current menu does not have a "print" command or items to be counted).
     */
    public function count(Query $query = null, $from = null)
    {
        $countRequest = new Request(
            $this->menu . '/print count-only=""',
            $query
        );
        $countRequest->setArgument('from', $from);
        $result = $this->client->sendSync($countRequest)->end()
            ->getProperty('ret');

        if (null === $result) {
            return -1;
        }
        if (Stream::isStream($result)) {
            $result = stream_get_contents($result);
        }
        return (int)$result;
    }

    /**
     * Gets all items in the current menu.
     *
     * Gets all items in the current menu, using a print request.
     *
     * @param array<string,string|resource>|array<int,string> $args  Additional
     *     arguments to pass to the request.
     *     Each array key is the name of the argument, and each array value is
     *     the value of the argument to be passed.
     *     Arguments without a value (i.e. empty arguments) can also be
     *     specified using a numeric key, and the name of the argument as the
     *     array value.
     *     The "follow" and "follow-only" arguments are prohibited,
     *     as they would cause a synchronous request to run forever, without
     *     allowing the results to be observed.
     *     If you need to use those arguments, use {@link static::newRequest()},
     *     and pass the resulting {@link Request} to {@link Client::sendAsync()}.
     *     The "count-only" argument is also prohibited, as results from it
     *     would not be consumable. Use {@link static::count()} for that.
     * @param Query|null                                      $query A query to
     *     filter items by.
     *     NULL to get all items.
     *
     * @return ResponseCollection A response collection with all
     *     {@link Response::TYPE_DATA} responses. The collection will be empty
     *     when there are no matching items.
     *
     * @throws NotSupportedException If $args contains prohibited arguments
     *     ("follow", "follow-only" or "count-only").
     *
     * @throws RouterErrorException When there's an error upon attempting to
     *     call the "print" command on the specified menu (e.g. if there's no
     *     "print" command at the menu to begin with).
     */
    public function getAll(array $args = array(), Query $query = null)
    {
        $printRequest = new Request($this->menu . '/print', $query);
        foreach ($args as $name => $value) {
            if (is_int($name)) {
                $printRequest->setArgument($value);
            } else {
                $printRequest->setArgument($name, $value);
            }
        }

        foreach (array('follow', 'follow-only', 'count-only') as $arg) {
            if ($printRequest->getArgument($arg) !== null) {
                throw new NotSupportedException(
                    "The argument '{$arg}' was specified, but is prohibited",
                    NotSupportedException::CODE_ARG_PROHIBITED,
                    null,
                    $arg
                );
            }
        }
        $responses = $this->client->sendSync($printRequest);

        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
            throw new RouterErrorException(
                'Error when reading items',
                RouterErrorException::CODE_GETALL_ERROR,
                null,
                $responses
            );
        }
        return $responses->getAllOfType(Response::TYPE_DATA);
    }

    /**
     * Puts a file on RouterOS's file system.
     *
     * Puts a file on RouterOS's file system, regardless of the current menu.
     * Note that this is a **VERY VERY VERY** time consuming method - it takes a
     * minimum of a little over 4 seconds, most of which are in sleep. It waits
     * 2 seconds after a file is first created (required to actually start
     * writing to the file), and another 2 seconds after its contents is written
     * (performed in order to verify success afterwards).
     * Similarly for removal (when $data is NULL) - there are two seconds in
     * sleep, used to verify the file was really deleted.
     *
     * If you want an efficient way of transferring files, use (T)FTP.
     * If you want an efficient way of removing files, use
     * {@link static::setMenu()} to move to the "/file" menu, and call
     * {@link static::remove()} without performing verification afterwards.
     *
     * @param string               $filename  The filename to write data in.
     * @param string|resource|null $data      The data the file is going to have
     *     as a string or a seekable stream.
     *     Setting the value to NULL removes a file of this name.
     *     If a seekable stream is provided, it is sent from its current
     *     position to its end, and the pointer is seeked back to its current
     *     position after sending.
     *     Non seekable streams, as well as all other types, are casted to a
     *     string.
     * @param bool                 $overwrite Whether to overwrite the file if
     *     it exists.
     *
     * @return bool TRUE on success, FALSE on failure.
     */
    public function filePutContents($filename, $data, $overwrite = false)
    {
        $printRequest = new Request(
            '/file/print .proplist=""',
            Query::where('name', $filename)
        );
        $fileExists = count($this->client->sendSync($printRequest)) > 1;

        if (null === $data) {
            if (!$fileExists) {
                return false;
            }
            $removeRequest = new Request('/file/remove');
            $this->client->sendSync(
                $removeRequest->setArgument('numbers', $filename)
            );
            //Required for RouterOS to REALLY remove the file.
            sleep(2);
            return !(count($this->client->sendSync($printRequest)) > 1);
        }

        if (!$overwrite && $fileExists) {
            return false;
        }
        $result = $this->client->sendSync(
            $printRequest->setArgument('file', $filename)
        );
        if (count($result->getAllOfType(Response::TYPE_ERROR)) > 0) {
            return false;
        }
        //Required for RouterOS to write the initial file.
        sleep(2);
        $setRequest = new Request('/file/set contents=""');
        $setRequest->setArgument('numbers', $filename);
        $this->client->sendSync($setRequest);
        $this->client->sendSync($setRequest->setArgument('contents', $data));
        //Required for RouterOS to write the file's new contents.
        sleep(2);

        $fileSize = $this->client->sendSync(
            $printRequest->setArgument('file', null)
                ->setArgument('.proplist', 'size')
        )->getProperty('size');
        if (Stream::isStream($fileSize)) {
            $fileSize = stream_get_contents($fileSize);
        }
        if (Communicator::isSeekableStream($data)) {
            return Communicator::seekableStreamLength($data) == $fileSize;
        } else {
            return sprintf('%u', strlen((string)$data)) === $fileSize;
        }
    }

    /**
     * Gets the contents of a specified file.
     *
     * @param string      $filename      The name of the file to get
     *     the contents of.
     * @param string|null $tmpScriptName In order to get the file's contents, a
     *     script is created at "/system script", the source of which is then
     *     overwritten with the file's contents, then retrieved from there,
     *     after which the script is removed.
     *     If this argument is left NULL, a random string,
     *     prefixed with the computer's name, is generated and used
     *     as the script's name.
     *     To eliminate any possibility of name clashes,
     *     you can specify your own name instead.
     *
     * @return string|resource The contents of the file as a string or as
     *     new PHP temp stream if the underlying
     *     {@link Client::isStreamingResponses()} is set to TRUE.
     *
     * @throws RouterErrorException When there's an error with the temporary
     *     script used to get the file, or if the file doesn't exist.
     */
    public function fileGetContents($filename, $tmpScriptName = null)
    {
        try {
            $responses = $this->exec(
                ':error ("&" . [/file get $filename contents]);',
                array('filename' => $filename),
                null,
                $tmpScriptName
            );
            throw new RouterErrorException(
                'Unable to read file through script (no error returned)',
                RouterErrorException::CODE_SCRIPT_FILE_ERROR,
                null,
                $responses
            );
        } catch (RouterErrorException $e) {
            if ($e->getCode() !== RouterErrorException::CODE_SCRIPT_RUN_ERROR) {
                throw $e;
            }
            $message = $e->getResponses()->getAllOfType(Response::TYPE_ERROR)
                ->getProperty('message');
            if (Stream::isStream($message)) {
                $successToken = fread($message, 1/*strlen('&')*/);
                if ('&' === $successToken) {
                    $messageCopy = fopen('php://temp', 'r+b');
                    stream_copy_to_stream($message, $messageCopy);
                    rewind($messageCopy);
                    fclose($message);
                    return $messageCopy;
                }
                rewind($message);
            } elseif (strpos($message, '&') === 0) {
                return substr($message, 1/*strlen('&')*/);
            }
            throw $e;
        }
    }

    /**
     * Performs an action on a bulk of items at the current menu.
     *
     * @param string $command What command to perform.
     * @param array  $args    Zero or more arguments can be specified,
     *     each being a criteria.
     *     If zero arguments are specified, matches all items.
     *     See {@link static::find()} for a description of what criteria are
     *     accepted.
     *
     * @return ResponseCollection Returns the response collection, allowing you
     *     to inspect errors, if any.
     */
    protected function doBulk($command, array $args = array())
    {
        $bulkRequest = new Request("{$this->menu}/{$command}");
        $bulkRequest->setArgument(
            'numbers',
            call_user_func_array(array($this, 'find'), $args)
        );
        return $this->client->sendSync($bulkRequest);
    }
}