<?php
/**
 * Copyright 2002-2017 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file LICENSE for license information (LGPL). If you
 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
 *
 * @category  Horde
 * @copyright 2002-2017 Horde LLC
 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
 * @package   Mime
 */

/**
 * This class represents the collection of header values for a single mail
 * message part.
 *
 * It supports the base e-mail spec (RFC 5322) and the MIME extensions to that
 * spec (RFC 2045).
 *
 * @author    Michael Slusarz <slusarz@horde.org>
 * @category  Horde
 * @copyright 2002-2017 Horde LLC
 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
 * @package   Mime
 */
class Horde_Mime_Headers
implements ArrayAccess, IteratorAggregate, Serializable
{
    /* Serialized version. */
    const VERSION = 3;

    /**
     * The default charset to use when parsing text parts with no charset
     * information.
     *
     * @todo Make this a non-static property or pass as parameter to static
     *       methods in Horde 6.
     * @var string
     */
    public static $defaultCharset = 'us-ascii';

    /**
     * Cached handler information for Header Element objects.
     *
     * @var array
     */
    protected static $_handlers = array();

    /**
     * The internal headers array.
     *
     * @var Horde_Support_CaseInsensitiveArray
     */
    protected $_headers;

    /**
     * Constructor.
     */
    public function __construct()
    {
        $this->_headers = new Horde_Support_CaseInsensitiveArray();
    }

    /**
     */
    public function __clone()
    {
        $copy = new Horde_Support_CaseInsensitiveArray();
        foreach ($this->_headers as $key => $val) {
            $copy[$key] = clone $val;
        }
        $this->_headers = $copy;
    }

    /**
     * Returns the headers in array format.
     *
     * @param array $opts  Optional parameters:
     * <pre>
     *   - broken_rfc2231: (boolean) Attempt to work around non-RFC
     *                     2231-compliant MUAs by generating both a RFC
     *                     2047-like parameter name and also the correct RFC
     *                     2231 parameter
     *                     DEFAULT: false
     *   - canonical: (boolean) Use canonical (RFC 822/2045) CRLF EOLs?
     *                DEFAULT: Uses "\n"
     *   - charset: (string) Encodes the headers using this charset. If empty,
     *              encodes using UTF-8.
     *              DEFAULT: No encoding.
     *   - defserver: (string) The default domain to append to mailboxes.
     *                DEFAULT: No default name.
     *   - lang: (string) The language to use when encoding.
     *           DEFAULT: None specified
     *   - nowrap: (integer) Don't wrap the headers.
     *             DEFAULT: Headers are wrapped.
     * </pre>
     *
     * @return array  The headers in array format. Keys are header names, but
     *                case sensitivity cannot be guaranteed. Values are
     *                header values.
     */
    public function toArray(array $opts = array())
    {
        $charset = array_key_exists('charset', $opts)
            ? (empty($opts['charset']) ? 'UTF-8' : $opts['charset'])
            : null;
        $eol = empty($opts['canonical'])
            ? $this->_eol
            : "\r\n";
        $ret = array();

        foreach ($this->_headers as $ob) {
            $sopts = array(
                'charset' => $charset
            );

            if (($ob instanceof Horde_Mime_Headers_Addresses) ||
                ($ob instanceof Horde_Mime_Headers_AddressesMulti)) {
                if (!empty($opts['defserver'])) {
                    $sopts['defserver'] = $opts['defserver'];
                }
            } elseif ($ob instanceof Horde_Mime_Headers_ContentParam) {
                $sopts['broken_rfc2231'] = !empty($opts['broken_rfc2231']);
                if (!empty($opts['lang'])) {
                    $sopts['lang'] = $opts['lang'];
                }
            }

            $tmp = array();

            foreach ($ob->sendEncode(array_filter($sopts)) as $val) {
                $val = $val ?? '';

                if (empty($opts['nowrap'])) {
                    /* Remove any existing linebreaks and wrap the line. */
                    $htext = $ob->name . ': ';
                    $val = ltrim(
                        substr(
                            wordwrap(
                                $htext . strtr(trim($val), array("\r" => '', "\n" => '')),
                                76,
                                $eol . ' '
                            ),
                            strlen($htext)
                        )
                    );
                }

                $tmp[] = $val;
            }

            $ret[$ob->name] = (count($tmp) == 1)
                ? reset($tmp)
                : $tmp;
        }

        return $ret;
    }

    /**
     * Returns all headers concatenated into a single string.
     *
     * @param array $opts  See toArray().
     *
     * @return string  The headers in string format.
     */
    public function toString(array $opts = array())
    {
        $eol = empty($opts['canonical'])
            ? $this->_eol
            : "\r\n";
        $text = '';

        foreach ($this->toArray($opts) as $key => $val) {
            foreach ((is_array($val) ? $val : array($val)) as $entry) {
                $text .= $key . ': ' . $entry . $eol;
            }
        }

        return $text . $eol;
    }

    /**
     * Add/append/replace a header.
     *
     * @param string $header  The header name.
     * @param string $value   The header value (UTF-8).
     * @param array $opts     DEPRECATED
     */
    public function addHeader($header, $value, array $opts = array())
    {
        /* Existing header? Add to that object. */
        $header = trim($header);
        if ($hdr = $this[$header]) {
            $hdr->setValue($value);
            return;
        }

        $classname = $this->_getHeaderClassName($header);

        try {
            $ob = new $classname($header, $value);
        } catch (InvalidArgumentException $e) {
            /* Ignore an invalid header. */
            return;
        } catch (Horde_Mime_Exception $e) {
            return;
        }

        switch ($classname) {
        case 'Horde_Mime_Headers_ContentParam_ContentDisposition':
        case 'Horde_Mime_Headers_ContentParam_ContentType':
            /* BC */
            if (!empty($opts['params'])) {
                foreach ($opts['params'] as $key => $val) {
                    $ob[$key] = $val;
                }
            }
            break;
        }

        $this->_headers[$ob->name] = $ob;
    }

    /**
     * Add a Horde_Mime_Headers_Element object to the current header list.
     *
     * @since 2.5.0
     *
     * @param Horde_Mime_Headers_Element $ob  Header object to add.
     * @param boolean $check                  Check that the header and object
     *                                        type match?
     *
     * @throws InvalidArgumentException
     */
    public function addHeaderOb(Horde_Mime_Headers_Element $ob, $check = false)
    {
        if ($check) {
            $cname = $this->_getHeaderClassName($ob->name);
            if (!($ob instanceof $cname)) {
                throw new InvalidArgumentException(sprintf(
                    'Object is not correct class: %s',
                    $cname
                ));
            }
        }

        /* Existing header? Add to that object. */
        if ($hdr = $this[$ob->name]) {
            $hdr->setValue($ob);
        } else {
            $this->_headers[$ob->name] = $ob;
        }
    }

    /**
     * Return the header class to use for a header name.
     *
     * @param string $header  The header name.
     *
     * @return string  The Horde_Mime_Headers_* class to use.
     */
    protected function _getHeaderClassName($header)
    {
        if (empty(self::$_handlers)) {
            $search = array(
                'Horde_Mime_Headers_Element_Single',
                'Horde_Mime_Headers_AddressesMulti',
                'Horde_Mime_Headers_Addresses',
                'Horde_Mime_Headers_ContentDescription',
                'Horde_Mime_Headers_ContentId',
                'Horde_Mime_Headers_ContentLanguage',
                'Horde_Mime_Headers_ContentParam_ContentDisposition',
                'Horde_Mime_Headers_ContentParam_ContentType',
                'Horde_Mime_Headers_ContentTransferEncoding',
                'Horde_Mime_Headers_Date',
                'Horde_Mime_Headers_Identification',
                'Horde_Mime_Headers_MessageId',
                'Horde_Mime_Headers_Mime',
                'Horde_Mime_Headers_MimeVersion',
                'Horde_Mime_Headers_Received',
                'Horde_Mime_Headers_Subject',
                'Horde_Mime_Headers_UserAgent'
            );

            foreach ($search as $val) {
                foreach ($val::getHandles() as $hdr) {
                    self::$_handlers[$hdr] = $val;
                }
            }
        }

        $header = Horde_String::lower($header);

        return isset(self::$_handlers[$header])
            ? self::$_handlers[$header]
            : 'Horde_Mime_Headers_Element_Multiple';
    }

    /**
     * Get a header from the header array.
     *
     * @param string $header  The header name.
     *
     * @return Horde_Mime_Headers_Element  Element object, or null if not
     *                                     found.
     */
    public function getHeader($header)
    {
        return $this[$header];
    }

    /**
     * Remove a header from the header array.
     *
     * @param string $header  The header name.
     */
    public function removeHeader($header)
    {
        unset($this[$header]);
    }

    /* Static methods. */

    /**
     * Builds a Horde_Mime_Headers object from header text.
     *
     * @param mixed $text  A text string (or, as of 2.3.0, a Horde_Stream
     *                     object or stream resource) containing the headers.
     *
     * @return Horde_Mime_Headers  A new Horde_Mime_Headers object.
     */
    public static function parseHeaders($text)
    {
        $curr = null;
        $headers = new Horde_Mime_Headers();
        $hdr_list = array();

        if ($text instanceof Horde_Stream) {
            $stream = $text;
            $stream->rewind();
        } else {
            $stream = new Horde_Stream_Temp();
            $stream->add($text, true);
        }

        while (!$stream->eof()) {
            if (!($val = rtrim($stream->getToChar("\n", false), "\r"))) {
                break;
            }

            if ($curr && (($val[0] == ' ') || ($val[0] == "\t"))) {
                $curr->text .= ' ' . ltrim($val);
            } else {
                $pos = strpos($val, ':');

                $curr = new stdClass;
                $curr->header = substr($val, 0, $pos);
                $curr->text = ltrim(substr($val, $pos + 1));

                $hdr_list[] = $curr;
            }
        }

        foreach ($hdr_list as $val) {
            /* When parsing, only keep the FIRST header seen for single value
             * text-only headers, since newer headers generally are appended
             * to the top of the message. */
            if (!($ob = $headers[$val->header]) ||
                !($ob instanceof Horde_Mime_Headers_Element_Single) ||
                ($ob instanceof Horde_Mime_Headers_Addresses)) {
                $headers->addHeader($val->header, rtrim($val->text));
            }
        }

        if (!($text instanceof Horde_Stream)) {
            $stream->close();
        }

        return $headers;
    }

    /* Serializable methods. */

    /**
     * Serialization.
     *
     * @return string  Serialized data.
     */
    public function serialize()
    {
        return serialize($this->__serialize());
    }

    /**
     * Unserialization.
     *
     * @param string $data  Serialized data.
     *
     * @throws Exception
     */
    public function unserialize($data)
    {
        $data = @unserialize($data);
        $this->__unserialize($data);
    }

    /**
     * @return array
     */
    public function __serialize()
    {
        return array(
            // Serialized data ID.
            self::VERSION,
            $this->_headers->getArrayCopy(),
            // TODO: BC
            $this->_eol
        );
    }

    /**
     * @param array $data
     * @return void
     * @throws Horde_Mime_Exception
     */
    public function __unserialize($data)
    {
        if (!isset($data[0]) ||
            ($data[0] != self::VERSION)) {
            throw new Horde_Mime_Exception('Cache version change');
        }

        $this->_headers = new Horde_Support_CaseInsensitiveArray($data[1]);
        // TODO: BC
        $this->_eol = $data[2];
    }

    /* ArrayAccess methods. */

    /**
     * Does header exist?
     *
     * @since 2.5.0
     *
     * @param string $header  Header name.
     *
     * @return boolean  True if header exists.
     */
    #[ReturnTypeWillChange]
    public function offsetExists($offset)
    {
        return isset($this->_headers[trim($offset)]);
    }

    /**
     * Return header element object.
     *
     * @since 2.5.0
     *
     * @param string $header  Header name.
     *
     * @return Horde_Mime_Headers_Element  Element object, or null if not
     *                                     found.
     */
    #[ReturnTypeWillChange]
    public function offsetGet($offset)
    {
        return $this->_headers[trim($offset)];
    }

    /**
     * Store a header element object.
     *
     * @since 2.5.0
     *
     * @param string $offset                   Not used.
     * @param Horde_Mime_Headers_Element $elt  Header element.
     */
    #[ReturnTypeWillChange]
    public function offsetSet($offset, $value)
    {
        $this->addHeaderOb($value);
    }

    /**
     * Remove a header element object.
     *
     * @since 2.5.0
     *
     * @param string $offset  Header name.
     */
    #[ReturnTypeWillChange]
    public function offsetUnset($offset)
    {
        unset($this->_headers[trim($offset)]);
    }

    /* IteratorAggregate function */

    /**
     * @since 2.5.0
     */
    #[ReturnTypeWillChange]
    public function getIterator()
    {
        return new ArrayIterator((array) $this->_headers);
    }

    /* Deprecated functions */

    /**
     * Handle deprecated methods.
     */
    public function __call($name, $arguments)
    {
        $d = new Horde_Mime_Headers_Deprecated($this);
        return call_user_func_array(array($d, $name), $arguments);
    }

    /**
     * Handle deprecated static methods.
     */
    public static function __callStatic($name, $arguments)
    {
        $d = new Horde_Mime_Headers_Deprecated();
        return call_user_func_array(array($d, $name), $arguments);
    }

    /**
     * @deprecated
     */
    protected $_eol = "\n";

    /**
     * @deprecated
     */
    public function setEOL($eol)
    {
        $this->_eol = $eol;
    }

    /**
     * @deprecated
     */
    public function getEOL()
    {
        return $this->_eol;
    }

    /* Constants for getValue(). @deprecated */
    const VALUE_STRING = 1;
    const VALUE_BASE = 2;
    const VALUE_PARAMS = 3;

}
