<?php

namespace Laminas\Http\Header;

use stdClass;

use function array_intersect;
use function array_shift;
use function array_walk;
use function count;
use function explode;
use function implode;
use function is_float;
use function is_int;
use function is_numeric;
use function is_string;
use function preg_match;
use function preg_match_all;
use function preg_replace_callback;
use function sprintf;
use function str_split;
use function stripslashes;
use function strlen;
use function strpos;
use function strtolower;
use function substr;
use function trim;
use function usort;
use function version_compare;

/**
 * Abstract Accept Header
 *
 * Naming conventions:
 *
 *    Accept: audio/mp3; q=0.2; version=0.5, audio/basic+mp3
 *   |------------------------------------------------------|  header line
 *   |------|                                                  field name
 *          |-----------------------------------------------|  field value
 *          |-------------------------------|                  field value part
 *          |------|                                           type
 *                  |--|                                       subtype
 *                  |--|                                       format
 *                                                |----|       subtype
 *                                                      |---|  format
 *                      |-------------------|                  parameter set
 *                              |-----------|                  parameter
 *                              |-----|                        parameter key
 *                                      |--|                   parameter value
 *                        |---|                                priority
 *
 * @see        http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1
 */
abstract class AbstractAccept implements HeaderInterface
{
    /** @var stdClass[] */
    protected $fieldValueParts = [];

    /** @var string */
    protected $regexAddType;

    /**
     * Determines if since last mutation the stack was sorted
     *
     * @var bool
     */
    protected $sorted = false;

    /**
     * Parse a full header line or just the field value part.
     *
     * @param string $headerLine
     */
    public function parseHeaderLine($headerLine)
    {
        if (strpos($headerLine, ':') !== false) {
            [$name, $value] = GenericHeader::splitHeaderLine($headerLine);
            if (strtolower($name) !== strtolower($this->getFieldName())) {
                $value = $headerLine; // This is just for preserve the BC.
            }
        } else {
            $value = $headerLine;
        }

        HeaderValue::assertValid($value);

        foreach ($this->getFieldValuePartsFromHeaderLine($value) as $value) {
            $this->addFieldValuePartToQueue($value);
        }
    }

    /**
     * Factory method: parse Accept header string
     *
     * @param  string $headerLine
     * @return static
     */
    public static function fromString($headerLine)
    {
        $obj = new static();
        $obj->parseHeaderLine($headerLine);
        return $obj;
    }

    /**
     * Parse the Field Value Parts represented by a header line
     *
     * @param string  $headerLine
     * @throws Exception\InvalidArgumentException If header is invalid.
     * @return array
     */
    public function getFieldValuePartsFromHeaderLine($headerLine)
    {
        // process multiple accept values, they may be between quotes
        if (
            ! preg_match_all('/(?:[^,"]|"(?:[^\\\"]|\\\.)*")+/', $headerLine, $values)
                || ! isset($values[0])
        ) {
            throw new Exception\InvalidArgumentException(
                'Invalid header line for ' . $this->getFieldName() . ' header string'
            );
        }

        $out = [];
        foreach ($values[0] as $value) {
            $value = trim($value);
            $out[] = $this->parseFieldValuePart($value);
        }

        return $out;
    }

    /**
     * Parse the accept params belonging to a media range
     *
     * @param string $fieldValuePart
     * @return stdClass
     */
    protected function parseFieldValuePart($fieldValuePart)
    {
        $raw = $subtypeWhole = $type = $fieldValuePart;
        if ($pos = strpos($fieldValuePart, ';')) {
            $type = substr($fieldValuePart, 0, $pos);
        }

        $params = $this->getParametersFromFieldValuePart($fieldValuePart);

        if ($pos = strpos($fieldValuePart, ';')) {
            $fieldValuePart = trim(substr($fieldValuePart, 0, $pos));
        }

        $format  = '*';
        $subtype = '*';

        return (object) [
            'typeString' => trim($fieldValuePart),
            'type'       => $type,
            'subtype'    => $subtype,
            'subtypeRaw' => $subtypeWhole,
            'format'     => $format,
            'priority'   => $params['q'] ?? 1,
            'params'     => $params,
            'raw'        => trim($raw),
        ];
    }

    /**
     * Parse the keys contained in the header line
     *
     * @param string $fieldValuePart
     * @return array
     */
    protected function getParametersFromFieldValuePart($fieldValuePart)
    {
        $params = [];
        if (($pos = strpos($fieldValuePart, ';')) !== false) {
            preg_match_all('/(?:[^;"]|"(?:[^\\\"]|\\\.)*")+/', $fieldValuePart, $paramsStrings);

            if (isset($paramsStrings[0])) {
                array_shift($paramsStrings[0]);
                $paramsStrings = $paramsStrings[0];
            }

            foreach ($paramsStrings as $param) {
                $explode = explode('=', $param, 2);

                if (count($explode) === 2) {
                    $value = trim($explode[1]);
                } else {
                    $value = null;
                }

                if (isset($value[0]) && $value[0] === '"' && substr($value, -1) === '"') {
                    $value = substr(substr($value, 1), 0, -1);
                }

                $params[trim($explode[0])] = stripslashes($value ?? '');
            }
        }

        return $params;
    }

    /**
     * Get field value
     *
     * @param array|null $values
     * @return string
     */
    public function getFieldValue($values = null)
    {
        if ($values === null) {
            return $this->getFieldValue($this->fieldValueParts);
        }

        $strings = [];
        foreach ($values as $value) {
            $params = $value->params;
            array_walk($params, [$this, 'assembleAcceptParam']);
            $strings[] = implode(';', [$value->typeString] + $params);
        }

        return implode(', ', $strings);
    }

    /**
     * Assemble and escape the field value parameters based on RFC 2616 section 2.1
     *
     * @todo someone should review this thoroughly
     * @param string $value
     * @param string $key
     * @return string
     */
    protected function assembleAcceptParam(&$value, $key)
    {
        $separators = ['(', ')', '<', '>', '@', ',', ';', ':', '/', '[', ']', '?', '=', '{', '}', ' ', "\t"];

        $escaped = preg_replace_callback(
            '/[[:cntrl:]"\\\\]/', // escape cntrl, ", \
            function ($v) {
                return '\\' . $v[0];
            },
            $value
        );

        if ($escaped === $value && ! array_intersect(str_split($value), $separators)) {
            $value = $key . ($value ? '=' . $value : '');
        } else {
            $value = $key . ($value ? '="' . $escaped . '"' : '');
        }

        return $value;
    }

    /**
     * Add a type, with the given priority
     *
     * @param  string $type
     * @param  int|float $priority
     * @param  array (optional) $params
     * @throws Exception\InvalidArgumentException
     * @return $this
     */
    protected function addType($type, $priority = 1, array $params = [])
    {
        if (! preg_match($this->regexAddType, $type)) {
            throw new Exception\InvalidArgumentException(sprintf(
                '%s expects a valid type; received "%s"',
                __METHOD__,
                (string) $type
            ));
        }

        if (
            ! is_int($priority) && ! is_float($priority) && ! is_numeric($priority)
            || $priority > 1 || $priority < 0
        ) {
            throw new Exception\InvalidArgumentException(sprintf(
                '%s expects a numeric priority; received %s',
                __METHOD__,
                (string) $priority
            ));
        }

        if ($priority !== 1) {
            $params = ['q' => sprintf('%01.1f', $priority)] + $params;
        }

        $assembledString = $this->getFieldValue(
            [(object) ['typeString' => $type, 'params' => $params]]
        );

        $value = $this->parseFieldValuePart($assembledString);
        $this->addFieldValuePartToQueue($value);
        return $this;
    }

    /**
     * Does the header have the requested type?
     *
     * @param  array|string $matchAgainst
     * @return bool
     */
    protected function hasType($matchAgainst)
    {
        return (bool) $this->match($matchAgainst);
    }

    /**
     * Match a media string against this header
     *
     * @param array|string $matchAgainst
     * @return Accept\FieldValuePart\AcceptFieldValuePart|bool The matched value or false
     */
    public function match($matchAgainst)
    {
        if (is_string($matchAgainst)) {
            $matchAgainst = $this->getFieldValuePartsFromHeaderLine($matchAgainst);
        }

        foreach ($this->getPrioritized() as $left) {
            foreach ($matchAgainst as $right) {
                if ($right->type === '*' || $left->type === '*') {
                    if ($this->matchAcceptParams($left, $right)) {
                        $left->setMatchedAgainst($right);

                        return $left;
                    }
                }

                if ($left->type === $right->type) {
                    if (
                        ($left->subtype === $right->subtype || ($right->subtype === '*' || $left->subtype === '*'))
                        && ($left->format === $right->format || $right->format === '*' || $left->format === '*')
                    ) {
                        if ($this->matchAcceptParams($left, $right)) {
                            $left->setMatchedAgainst($right);

                            return $left;
                        }
                    }
                }
            }
        }

        return false;
    }

    /**
     * Return a match where all parameters in argument #1 match those in argument #2
     *
     * @param array $match1
     * @param array $match2
     * @return bool|array
     */
    protected function matchAcceptParams($match1, $match2)
    {
        foreach ($match2->params as $key => $value) {
            if (isset($match1->params[$key])) {
                if (strpos($value, '-')) {
                    preg_match(
                        '/^(?|([^"-]*)|"([^"]*)")-(?|([^"-]*)|"([^"]*)")\z/',
                        $value,
                        $pieces
                    );

                    if (
                        count($pieces) === 3
                        && (version_compare($pieces[1], $match1->params[$key], '<=')
                            xor version_compare($pieces[2], $match1->params[$key], '>='))
                    ) {
                        return false;
                    }
                } elseif (strpos($value, '|')) {
                    $options = explode('|', $value);
                    $good    = false;
                    foreach ($options as $option) {
                        if ($option === $match1->params[$key]) {
                            $good = true;
                            break;
                        }
                    }

                    if (! $good) {
                        return false;
                    }
                } elseif ($match1->params[$key] !== $value) {
                    return false;
                }
            }
        }

        return $match1;
    }

    /**
     * Add a key/value combination to the internal queue
     *
     * @param stdClass $value
     * @return void
     */
    protected function addFieldValuePartToQueue($value)
    {
        $this->fieldValueParts[] = $value;
        $this->sorted            = false;
    }

    /**
     * Sort the internal Field Value Parts
     *
     * @see rfc2616 sect 14.1
     * Media ranges can be overridden by more specific media ranges or
     * specific media types. If more than one media range applies to a given
     * type, the most specific reference has precedence. For example,
     *
     * Accept: text/*, text/html, text/html;level=1, * /*
     *
     * have the following precedence:
     *
     * 1) text/html;level=1
     * 2) text/html
     * 3) text/*
     * 4) * /*
     *
     * @return void
     */
    protected function sortFieldValueParts()
    {
        $sort = static function (object $a, object $b): int {
            // If A has higher precedence than B, return -1.
            if ($a->priority > $b->priority) {
                return -1;
            } elseif ($a->priority < $b->priority) {
                return 1;
            }

            // Asterisks
            $values = ['type', 'subtype', 'format'];
            foreach ($values as $value) {
                if ($a->$value === '*' && $b->$value !== '*') {
                    return 1;
                } elseif ($b->$value === '*' && $a->$value !== '*') {
                    return -1;
                }
            }

            if ($a->type === 'application' && $b->type !== 'application') {
                return -1;
            } elseif ($b->type === 'application' && $a->type !== 'application') {
                return 1;
            }

            // @todo count number of dots in case of type==application in subtype

            // So far they're still the same. Longest string length may be more specific
            if (strlen($a->raw) === strlen($b->raw)) {
                return 0;
            }
            return strlen($a->raw) > strlen($b->raw) ? -1 : 1;
        };

        usort($this->fieldValueParts, $sort);
        $this->sorted = true;
    }

    /**
     * @return array with all the keys, values and parameters this header represents:
     */
    public function getPrioritized()
    {
        if (! $this->sorted) {
            $this->sortFieldValueParts();
        }

        return $this->fieldValueParts;
    }
}
