<?php
/**
 * This file is part of the ZBateson\MailMimeParser project.
 *
 * @license http://opensource.org/licenses/bsd-license.php BSD
 */

namespace ZBateson\MailMimeParser\Message;

use ArrayIterator;
use IteratorAggregate;
use Psr\Log\LoggerInterface;
use Traversable;
use ZBateson\MailMimeParser\ErrorBag;
use ZBateson\MailMimeParser\Header\HeaderFactory;
use ZBateson\MailMimeParser\Header\IHeader;

/**
 * Maintains a collection of headers for a part.
 *
 * @implements IteratorAggregate<int, array<string>>
 *
 * @author Zaahid Bateson
 */
class PartHeaderContainer extends ErrorBag implements IteratorAggregate
{
    /**
     * @var HeaderFactory the HeaderFactory object used for created headers
     */
    protected HeaderFactory $headerFactory;

    /**
     * @var string[][] Each element in the array is an array with its first
     * element set to the header's name, and the second its value.
     */
    private array $headers = [];

    /**
     * @var array<int, ?IHeader> Each element is an IHeader
     *      representing the header at the same index in the $headers array.  If
     *      an IHeader has not been constructed for the header at that index,
     *      the element would be set to null.
     */
    private array $headerObjects = [];

    /**
     * @var array<string, int[]> Maps header names by their "normalized" (lower-cased,
     *      non-alphanumeric characters stripped) name to an array of indexes in
     *      the $headers array.  For example:
     *      $headerMap['contenttype'] = [ 1, 4 ]
     *      would indicate that the headers in $headers[1] and $headers[4] are
     *      both headers with the name 'Content-Type' or 'contENTtype'.
     */
    private array $headerMap = [];

    /**
     * @var int the next index to use for $headers and $headerObjects.
     */
    private int $nextIndex = 0;

    /**
     * Pass a PartHeaderContainer as the second parameter.  This is useful when
     * creating a new MimePart with this PartHeaderContainer and the original
     * container is needed for parsing and changes to the header in the part
     * should not affect parsing.
     *
     * @param PartHeaderContainer $cloneSource the original container to clone
     *        from
     */
    public function __construct(
        LoggerInterface $logger,
        HeaderFactory $headerFactory,
        ?PartHeaderContainer $cloneSource = null
    ) {
        parent::__construct($logger);
        $this->headerFactory = $headerFactory;
        if ($cloneSource !== null) {
            $this->headers = $cloneSource->headers;
            $this->headerObjects = $cloneSource->headerObjects;
            $this->headerMap = $cloneSource->headerMap;
            $this->nextIndex = $cloneSource->nextIndex;
            $this->copyErrorsFrom($cloneSource);
        }
    }

    /**
     * Returns true if the passed header exists in this collection.
     */
    public function exists(string $name, int $offset = 0) : bool
    {
        $s = $this->headerFactory->getNormalizedHeaderName($name);
        return isset($this->headerMap[$s][$offset]);
    }

    /**
     * Returns an array of header indexes with names that more closely match
     * the passed $name if available: for instance if there are two headers in
     * an email, "Content-Type" and "ContentType", and the query is for a header
     * with the name "Content-Type", only headers that match exactly
     * "Content-Type" would be returned.
     *
     * @return int[]|null
     */
    private function getAllWithOriginalHeaderNameIfSet(string $name) : ?array
    {
        $s = $this->headerFactory->getNormalizedHeaderName($name);
        if (isset($this->headerMap[$s])) {
            $filtered = \array_filter($this->headerMap[$s], fn($h) => \strcasecmp($this->headers[$h][0], $name) === 0);
            return (!empty($filtered)) ? $filtered : $this->headerMap[$s];
        }
        return null;
    }

    /**
     * Returns the IHeader object for the header with the given $name, or null
     * if none exist.
     *
     * An optional offset can be provided, which defaults to the first header in
     * the collection when more than one header with the same name exists.
     *
     * Note that mime headers aren't case sensitive.
     */
    public function get(string $name, int $offset = 0) : ?IHeader
    {
        $a = $this->getAllWithOriginalHeaderNameIfSet($name);
        if (!empty($a) && isset($a[$offset])) {
            return $this->getByIndex($a[$offset]);
        }
        return null;
    }

    /**
     * Returns the IHeader object for the header with the given $name, or null
     * if none exist, using the passed $iHeaderClass to construct it.
     *
     * An optional offset can be provided, which defaults to the first header in
     * the collection when more than one header with the same name exists.
     *
     * Note that mime headers aren't case sensitive.
     */
    public function getAs(string $name, string $iHeaderClass, int $offset = 0) : ?IHeader
    {
        $a = $this->getAllWithOriginalHeaderNameIfSet($name);
        if (!empty($a) && isset($a[$offset])) {
            return $this->getByIndexAs($a[$offset], $iHeaderClass);
        }
        return null;
    }

    /**
     * Returns all headers with the passed name.
     *
     * @return IHeader[]
     */
    public function getAll(string $name) : array
    {
        $a = $this->getAllWithOriginalHeaderNameIfSet($name);
        if (!empty($a)) {
            return \array_map(fn($index) => $this->getByIndex($index), $a);
        }
        return [];
    }

    /**
     * Returns the header in the headers array at the passed 0-based integer
     * index or null if one doesn't exist.
     */
    private function getByIndex(int $index) : ?IHeader
    {
        if (!isset($this->headers[$index])) {
            return null;
        }
        if ($this->headerObjects[$index] === null) {
            $this->headerObjects[$index] = $this->headerFactory->newInstance(
                $this->headers[$index][0],
                $this->headers[$index][1]
            );
        }
        return $this->headerObjects[$index];
    }

    /**
     * Returns the header in the headers array at the passed 0-based integer
     * index or null if one doesn't exist, using the passed $iHeaderClass to
     * construct it.
     */
    private function getByIndexAs(int $index, string $iHeaderClass) : ?IHeader
    {
        if (!isset($this->headers[$index])) {
            return null;
        }
        if ($this->headerObjects[$index] !== null && \get_class($this->headerObjects[$index]) === $iHeaderClass) {
            return $this->headerObjects[$index];
        }
        return $this->headerFactory->newInstanceOf(
            $this->headers[$index][0],
            $this->headers[$index][1],
            $iHeaderClass
        );
    }

    /**
     * Removes the header from the collection with the passed name.  Defaults to
     * removing the first instance of the header for a collection that contains
     * more than one with the same passed name.
     *
     * @return bool true if a header was found and removed.
     */
    public function remove(string $name, int $offset = 0) : bool
    {
        $s = $this->headerFactory->getNormalizedHeaderName($name);
        if (isset($this->headerMap[$s][$offset])) {
            $index = $this->headerMap[$s][$offset];
            \array_splice($this->headerMap[$s], $offset, 1);
            unset($this->headers[$index], $this->headerObjects[$index]);
            return true;
        }
        return false;
    }

    /**
     * Removes all headers that match the passed name.
     *
     * @return bool true if one or more headers were removed.
     */
    public function removeAll(string $name) : bool
    {
        $s = $this->headerFactory->getNormalizedHeaderName($name);
        if (!empty($this->headerMap[$s])) {
            foreach ($this->headerMap[$s] as $i) {
                unset($this->headers[$i], $this->headerObjects[$i]);
            }
            $this->headerMap[$s] = [];
            return true;
        }
        return false;
    }

    /**
     * Adds the header to the collection.
     */
    public function add(string $name, string $value) : static
    {
        $s = $this->headerFactory->getNormalizedHeaderName($name);
        $this->headers[$this->nextIndex] = [$name, $value];
        $this->headerObjects[$this->nextIndex] = null;
        if (!isset($this->headerMap[$s])) {
            $this->headerMap[$s] = [];
        }
        $this->headerMap[$s][] = $this->nextIndex;
        $this->nextIndex++;
        return $this;
    }

    /**
     * If a header exists with the passed name, and at the passed offset if more
     * than one exists, its value is updated.
     *
     * If a header with the passed name doesn't exist at the passed offset, it
     * is created at the next available offset (offset is ignored when adding).
     */
    public function set(string $name, string $value, int $offset = 0) : static
    {
        $s = $this->headerFactory->getNormalizedHeaderName($name);
        if (!isset($this->headerMap[$s][$offset])) {
            $this->add($name, $value);
            return $this;
        }
        $i = $this->headerMap[$s][$offset];
        $this->headers[$i] = [$name, $value];
        $this->headerObjects[$i] = null;
        return $this;
    }

    /**
     * Returns an array of IHeader objects representing all headers in this
     * collection.
     *
     * @return IHeader[]
     */
    public function getHeaderObjects() : array
    {
        return \array_filter(\array_map($this->getByIndex(...), \array_keys($this->headers)));
    }

    /**
     * Returns an array of headers in this collection.  Each returned element in
     * the array is an array with the first element set to the name, and the
     * second its value:
     *
     * [
     *     [ 'Header-Name', 'Header Value' ],
     *     [ 'Second-Header-Name', 'Second-Header-Value' ],
     *     // etc...
     * ]
     *
     * @return string[][]
     */
    public function getHeaders() : array
    {
        return \array_values(\array_filter($this->headers));
    }

    /**
     * Returns an iterator to the headers in this collection.  Each returned
     * element is an array with its first element set to the header's name, and
     * the second to its value:
     *
     * [ 'Header-Name', 'Header Value' ]
     *
     * @return Traversable<int, array<string>>
     */
    public function getIterator() : Traversable
    {
        return new ArrayIterator($this->getHeaders());
    }

    public function getErrorBagChildren() : array
    {
        return \array_values(\array_filter($this->headerObjects));
    }
}
