<?php
/**
 * File containing the ezcMailImapSet class.
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 *
 * @package Mail
 * @version //autogen//
 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0
 */

/**
 * ezcMailImapSet is an internal class that fetches a series of mail
 * from the IMAP server.
 *
 * The IMAP set works on an existing connection and a list of the messages that
 * the user wants to fetch. The user must accept all the data for each mail for
 * correct behaviour.
 *
 * @package Mail
 * @version //autogen//
 */
class ezcMailImapSet implements ezcMailParserSet
{
    /**
     * Holds the list of messages that the user wants to retrieve from the server.
     *
     * @var array(int)
     */
    private $messages;

    /**
     * Holds the current message the user is fetching.
     *
     * The variable is null before the first message and false after
     * the last message has been fetched.
     *
     * @var int
     */
    private $currentMessage = null;

    /**
     * Holds the line that will be read-ahead in order to determine the trailing paranthesis.
     *
     * @var string
     */
    private $nextData = null;

    /**
     * This variable is true if there is more data in the mail that is being fetched.
     *
     * It is false if there is no mail being fetched currently or if all the data of the current mail
     * has been fetched.
     *
     * @var bool
     */
    private $hasMoreMailData = false;

    /**
     * Holds if mail should be deleted from the server after retrieval.
     *
     * @var bool
     */
    private $deleteFromServer = false;

    /**
     * Used to generate a tag for sending commands to the IMAP server.
     *
     * @var string
     */
    private $currentTag = 'A0000';

    /**
     * Holds the mode in which the IMAP commands operate.
     *
     * @var string
     */
    private $uid;

    /**
     * Holds the options for an IMAP mail set.
     *
     * @var ezcMailImapSetOptions
     */
    private $options;

    /**
     * Holds the number of bytes to read from the IMAP server.
     *
     * It is set before starting to read a message from the information
     * returned by the IMAP server in this form:
     *
     * <code>
     * * 2 FETCH (FLAGS (\Answered \Seen) RFC822 {377}
     * </code>
     *
     * In this example, $this->bytesToRead will be set to 377.
     *
     * @var int
     */
    private $bytesToRead = false;

    /**
     * Constructs a new IMAP parser set that will fetch the messages $messages.
     *
     * $connection must hold a valid connection to a IMAP server that is ready
     * to retrieve the messages.
     *
     * If $deleteFromServer is set to true the messages will be deleted after retrieval.
     *
     * See {@link ezcMailImapSetOptions} for options you can set to IMAP sets.
     *
     * @throws ezcMailTransportException
     *         if the server sent a negative response
     * @param ezcMailTransportConnection $connection
     * @param array(int) $messages
     * @param bool $deleteFromServer
     * @param ezcMailImapSetOptions|array(string=>mixed) $options
     */
    public function __construct(ezcMailTransportConnection $connection, array $messages, $deleteFromServer = false, $options = array())
    {
        if ($options instanceof ezcMailImapSetOptions) {
            $this->options = $options;
        } else if (is_array($options)) {
            $this->options = new ezcMailImapSetOptions($options);
        } else {
            throw new ezcBaseValueException("options", $options, "ezcMailImapSetOptions|array");
        }

        $this->connection = $connection;
        $this->messages = $messages;
        $this->deleteFromServer = $deleteFromServer;
        $this->nextData = null;

        $this->uid = ($this->options->uidReferencing) ? ezcMailImapTransport::UID : ezcMailImapTransport::NO_UID;
    }

    /**
     * Returns true if all the data has been fetched from this set.
     *
     * @return bool
     */
    public function isFinished()
    {
        return $this->currentMessage === false ? true : false;
    }

    /**
     * Returns one line of data from the current mail in the set.
     *
     * Null is returned if there is no current mail in the set or
     * the end of the mail is reached,
     *
     * @return string
     */
    public function getNextLine()
    {
        if ($this->currentMessage === null) {
            // instead of calling $this->nextMail() in the constructor, it is called
            // here, to avoid sending commands to the server when creating the set, and
            // instead send the server commands when parsing the set (see ezcMailParser).
            $this->nextMail();
        }
        if ($this->hasMoreMailData) {
            if ($this->bytesToRead !== false && $this->bytesToRead >= 0) {
                $data = $this->connection->getLine();
                $this->bytesToRead -= strlen($data);

                // modified for issue #13878 (Endless loop in ezcMailParser):
                // removed wrong checks (ending in ')' check and ending with tag check (e.g. 'A0002'))
                if ($this->bytesToRead <= 0) {
                    if ($this->bytesToRead < 0) {
                        $data = substr($data, 0, strlen($data) + $this->bytesToRead); //trim( $data, ")\r\n" );
                    }

                    if ($this->bytesToRead === 0) {
                        // hack for Microsoft Exchange, which sometimes puts
                        // FLAGS (\Seen)) at the end of a message instead of before (!)
                        if (strlen(trim($data, ")\r\n") !== strlen($data) - 3)) {
                            // if the last line in a mail does not end with ")\r\n"
                            // then read an extra line and discard it
                            $extraData = $this->connection->getLine();
                        }
                    }

                    $this->hasMoreMailData = false;
                    // remove the mail if required by the user.
                    if ($this->deleteFromServer === true) {
                        $tag = $this->getNextTag();
                        $this->connection->sendData("{$tag} {$this->uid}STORE {$this->currentMessage} +FLAGS (\\Deleted)");
                        // skip OK response ("{$tag} OK Store completed.")
                        $response = $this->getResponse($tag);
                    }
                    return $data;
                }
            }
            return $data;
        }
        return null;
    }

    /**
     * Moves the set to the next mail and returns true upon success.
     *
     * False is returned if there are no more mail in the set.
     *
     * @throws ezcMailTransportException
     *         if the server sent a negative response
     * @return bool
     */
    public function nextMail()
    {
        if ($this->currentMessage === null) {
            $this->currentMessage = reset($this->messages);
        } else {
            $this->currentMessage = next($this->messages);
        }
        $this->nextData = null;
        $this->bytesToRead = false;
        if ($this->currentMessage !== false) {
            $tag = $this->getNextTag();
            $this->connection->sendData("{$tag} {$this->uid}FETCH {$this->currentMessage} RFC822");
            $response = $this->connection->getLine();
            if (strpos($response, ' NO ') !== false ||
                strpos($response, ' BAD ') !== false
            ) {
                throw new ezcMailTransportException("The IMAP server sent a negative reply when requesting mail.");
            } else {
                $response = $this->getResponse('FETCH (', $response);
                if (strpos($response, 'FETCH (') !== false) {
                    $this->hasMoreMailData = true;
                    // retrieve the message size from $response, eg. if $response is:
                    // * 2 FETCH (FLAGS (\Answered \Seen) RFC822 {377}
                    // then $this->bytesToRead will be 377
                    preg_match('/\{(.*)\}/', $response, $matches);
                    if (count($matches) > 0) {
                        $this->bytesToRead = (int)$matches[1];
                    }
                    return true;
                } else {
                    $response = $this->getResponse($tag);
                    if (strpos($response, 'OK ') === false) {
                        throw new ezcMailTransportException("The IMAP server sent a negative reply when requesting mail.");
                    }
                }
            }
        }
        return false;
    }

    /**
     * Reads the responses from the server until encountering $tag.
     *
     * In IMAP, each command sent by the client is prepended with a
     * alphanumeric tag like 'A1234'. The server sends the response
     * to the client command as lines, and the last line in the response
     * is prepended with the same tag, and it contains the status of
     * the command completion ('OK', 'NO' or 'BAD').
     *
     * Sometimes the server sends alerts and response lines from other
     * commands before sending the tagged line, so this method just
     * reads all the responses until it encounters $tag.
     *
     * It returns the tagged line to be processed by the calling method.
     *
     * If $response is specified, then it will not read the response
     * from the server before searching for $tag in $response.
     *
     * Before calling this method, a connection to the IMAP server must be
     * established.
     *
     * @param string $tag
     * @param string $response
     * @return string
     */
    private function getResponse($tag = null, $response = null)
    {
        if (is_null($response)) {
            $response = $this->connection->getLine();
        }
        while (strpos($response, $tag) === false) {
            if (strpos($response, ' BAD ') !== false ||
                strpos($response, ' NO ') !== false
            ) {
                break;
            }
            $response = $this->connection->getLine();
        }
        return $response;
    }

    /**
     * Generates the next IMAP tag to prepend to client commands.
     *
     * The structure of the IMAP tag is Axxxx, where:
     *  - A is a letter (uppercase for conformity)
     *  - x is a digit from 0 to 9
     *
     * example of generated tag: T5439
     *
     * It uses the class variable $this->currentTag.
     *
     * Everytime it is called, the tag increases by 1.
     *
     * If it reaches the last tag, it wraps around to the first tag.
     *
     * By default, the first generated tag is A0001.
     *
     * @return string
     */
    private function getNextTag()
    {
        $tagLetter = substr($this->currentTag, 0, 1);
        $tagNumber = intval(substr($this->currentTag, 1));
        $tagNumber++;
        if ($tagLetter == 'Z' && $tagNumber == 10000) {
            $tagLetter = 'A';
            $tagNumber = 1;
        }
        if ($tagNumber == 10000) {
            $tagLetter++;
            $tagNumber = 0;
        }
        $this->currentTag = $tagLetter . sprintf("%04s", $tagNumber);
        return $this->currentTag;
    }

    /**
     * Returns whether the set has mails.
     *
     * @return bool
     */
    public function hasData()
    {
        return count($this->messages);
    }

    /**
     * Returns message numbers from the current set.
     *
     * @return array(int)
     */
    public function getMessageNumbers()
    {
        return $this->messages;
    }
}

?>
