<?php
/**
 * @see       https://github.com/zendframework/zend-form for the canonical source repository
 * @copyright Copyright (c) 2005-2018 Zend Technologies USA Inc. (https://www.zend.com)
 * @license   https://github.com/zendframework/zend-form/blob/master/LICENSE.md New BSD License
 */

namespace Zend\Form\View\Helper;

use Zend\Escaper\Exception\RuntimeException as EscaperException;
use Zend\Form\ElementInterface;
use Zend\Form\Exception\InvalidArgumentException;
use Zend\I18n\View\Helper\AbstractTranslatorHelper as BaseAbstractHelper;
use Zend\View\Helper\Doctype;
use Zend\View\Helper\EscapeHtml;
use Zend\View\Helper\EscapeHtmlAttr;

/**
 * Base functionality for all form view helpers
 */
abstract class AbstractHelper extends BaseAbstractHelper
{
    /**
     * The default translatable HTML attributes
     *
     * @var array
     */
    protected static $defaultTranslatableHtmlAttributes = [
        'title' => true,
    ];

    /**
     * The default translatable HTML attribute prefixes
     *
     * @var array
     */
    protected static $defaultTranslatableHtmlAttributePrefixes = [];

    /**
     * Standard boolean attributes, with expected values for enabling/disabling
     *
     * @var array
     */
    protected $booleanAttributes = [
        'autofocus'    => ['on' => 'autofocus', 'off' => ''],
        'checked'      => ['on' => 'checked',   'off' => ''],
        'disabled'     => ['on' => 'disabled',  'off' => ''],
        'multiple'     => ['on' => 'multiple',  'off' => ''],
        'readonly'     => ['on' => 'readonly',  'off' => ''],
        'required'     => ['on' => 'required',  'off' => ''],
        'selected'     => ['on' => 'selected',  'off' => ''],
    ];

    /**
     * Translatable attributes
     *
     * @var array
     */
    protected $translatableAttributes = [
        'placeholder' => true,
    ];

    /**
     * Prefixes of translatable HTML attributes
     *
     * @var array
     */
    protected $translatableAttributePrefixes = [];

    /**
     * @var Doctype
     */
    protected $doctypeHelper;

    /**
     * @var EscapeHtml
     */
    protected $escapeHtmlHelper;

    /**
     * @var EscapeHtmlAttr
     */
    protected $escapeHtmlAttrHelper;

    /**
     * Attributes globally valid for all tags
     *
     * @var array
     */
    protected $validGlobalAttributes = [
        'accesskey'          => true,
        'class'              => true,
        'contenteditable'    => true,
        'contextmenu'        => true,
        'dir'                => true,
        'draggable'          => true,
        'dropzone'           => true,
        'hidden'             => true,
        'id'                 => true,
        'lang'               => true,
        'onabort'            => true,
        'onblur'             => true,
        'oncanplay'          => true,
        'oncanplaythrough'   => true,
        'onchange'           => true,
        'onclick'            => true,
        'oncontextmenu'      => true,
        'ondblclick'         => true,
        'ondrag'             => true,
        'ondragend'          => true,
        'ondragenter'        => true,
        'ondragleave'        => true,
        'ondragover'         => true,
        'ondragstart'        => true,
        'ondrop'             => true,
        'ondurationchange'   => true,
        'onemptied'          => true,
        'onended'            => true,
        'onerror'            => true,
        'onfocus'            => true,
        'oninput'            => true,
        'oninvalid'          => true,
        'onkeydown'          => true,
        'onkeypress'         => true,
        'onkeyup'            => true,
        'onload'             => true,
        'onloadeddata'       => true,
        'onloadedmetadata'   => true,
        'onloadstart'        => true,
        'onmousedown'        => true,
        'onmousemove'        => true,
        'onmouseout'         => true,
        'onmouseover'        => true,
        'onmouseup'          => true,
        'onmousewheel'       => true,
        'onpause'            => true,
        'onplay'             => true,
        'onplaying'          => true,
        'onprogress'         => true,
        'onratechange'       => true,
        'onreadystatechange' => true,
        'onreset'            => true,
        'onscroll'           => true,
        'onseeked'           => true,
        'onseeking'          => true,
        'onselect'           => true,
        'onshow'             => true,
        'onstalled'          => true,
        'onsubmit'           => true,
        'onsuspend'          => true,
        'ontimeupdate'       => true,
        'onvolumechange'     => true,
        'onwaiting'          => true,
        'role'               => true,
        'spellcheck'         => true,
        'style'              => true,
        'tabindex'           => true,
        'title'              => true,
        'xml:base'           => true,
        'xml:lang'           => true,
        'xml:space'          => true,
    ];

    /**
     * Attribute prefixes valid for all tags
     *
     * @var array
     */
    protected $validTagAttributePrefixes = [
        'data-',
        'aria-',
        'x-',
    ];

    /**
     * Attributes valid for the tag represented by this helper
     *
     * This should be overridden in extending classes
     *
     * @var array
     */
    protected $validTagAttributes = [
    ];

    /**
     * Set value for doctype
     *
     * @param  string $doctype
     * @return AbstractHelper
     */
    public function setDoctype($doctype)
    {
        $this->getDoctypeHelper()->setDoctype($doctype);
        return $this;
    }

    /**
     * Get value for doctype
     *
     * @return string
     */
    public function getDoctype()
    {
        return $this->getDoctypeHelper()->getDoctype();
    }

    /**
     * Set value for character encoding
     *
     * @param  string $encoding
     * @return AbstractHelper
     */
    public function setEncoding($encoding)
    {
        $this->getEscapeHtmlHelper()->setEncoding($encoding);
        $this->getEscapeHtmlAttrHelper()->setEncoding($encoding);
        return $this;
    }

    /**
     * Get character encoding
     *
     * @return string
     */
    public function getEncoding()
    {
        return $this->getEscapeHtmlHelper()->getEncoding();
    }

    /**
     * Create a string of all attribute/value pairs
     *
     * Escapes all attribute values
     *
     * @param  array $attributes
     * @return string
     */
    public function createAttributesString(array $attributes)
    {
        $attributes = $this->prepareAttributes($attributes);
        $escape     = $this->getEscapeHtmlHelper();
        $escapeAttr = $this->getEscapeHtmlAttrHelper();
        $strings    = [];

        foreach ($attributes as $key => $value) {
            $key = strtolower($key);

            if (! $value && isset($this->booleanAttributes[$key])) {
                // Skip boolean attributes that expect empty string as false value
                if ('' === $this->booleanAttributes[$key]['off']) {
                    continue;
                }
            }

            //check if attribute is translatable and translate it
            $value = $this->translateHtmlAttributeValue($key, $value);

            // @todo Escape event attributes like AbstractHtmlElement view helper does in htmlAttribs ??
            try {
                $escapedAttribute = $escapeAttr($value);
                $strings[] = sprintf('%s="%s"', $escape($key), $escapedAttribute);
            } catch (EscaperException $x) {
                // If an escaper exception happens, escape only the key, and use a blank value.
                $strings[] = sprintf('%s=""', $escape($key));
            }
        }

        return implode(' ', $strings);
    }

    /**
     * Get the ID of an element
     *
     * If no ID attribute present, attempts to use the name attribute.
     * If no name attribute is present, either, returns null.
     *
     * @param  ElementInterface $element
     * @return null|string
     */
    public function getId(ElementInterface $element)
    {
        $id = $element->getAttribute('id');
        if (null !== $id) {
            return $id;
        }

        return $element->getName();
    }

    /**
     * Get the closing bracket for an inline tag
     *
     * Closes as either "/>" for XHTML doctypes or ">" otherwise.
     *
     * @return string
     */
    public function getInlineClosingBracket()
    {
        $doctypeHelper = $this->getDoctypeHelper();
        if ($doctypeHelper->isXhtml()) {
            return '/>';
        }
        return '>';
    }

    /**
     * Retrieve the doctype helper
     *
     * @return Doctype
     */
    protected function getDoctypeHelper()
    {
        if ($this->doctypeHelper) {
            return $this->doctypeHelper;
        }

        if (method_exists($this->view, 'plugin')) {
            $this->doctypeHelper = $this->view->plugin('doctype');
        }

        if (! $this->doctypeHelper instanceof Doctype) {
            $this->doctypeHelper = new Doctype();
        }

        return $this->doctypeHelper;
    }

    /**
     * Retrieve the escapeHtml helper
     *
     * @return EscapeHtml
     */
    protected function getEscapeHtmlHelper()
    {
        if ($this->escapeHtmlHelper) {
            return $this->escapeHtmlHelper;
        }

        if (method_exists($this->view, 'plugin')) {
            $this->escapeHtmlHelper = $this->view->plugin('escapehtml');
        }

        if (! $this->escapeHtmlHelper instanceof EscapeHtml) {
            $this->escapeHtmlHelper = new EscapeHtml();
        }

        return $this->escapeHtmlHelper;
    }

    /**
     * Retrieve the escapeHtmlAttr helper
     *
     * @return EscapeHtmlAttr
     */
    protected function getEscapeHtmlAttrHelper()
    {
        if ($this->escapeHtmlAttrHelper) {
            return $this->escapeHtmlAttrHelper;
        }

        if (method_exists($this->view, 'plugin')) {
            $this->escapeHtmlAttrHelper = $this->view->plugin('escapehtmlattr');
        }

        if (! $this->escapeHtmlAttrHelper instanceof EscapeHtmlAttr) {
            $this->escapeHtmlAttrHelper = new EscapeHtmlAttr();
        }

        return $this->escapeHtmlAttrHelper;
    }

    /**
     * Prepare attributes for rendering
     *
     * Ensures appropriate attributes are present (e.g., if "name" is present,
     * but no "id", sets the latter to the former).
     *
     * Removes any invalid attributes
     *
     * @param  array $attributes
     * @return array
     */
    protected function prepareAttributes(array $attributes)
    {
        foreach ($attributes as $key => $value) {
            $attribute = strtolower($key);

            if (! isset($this->validGlobalAttributes[$attribute])
                && ! isset($this->validTagAttributes[$attribute])
                && ! $this->hasAllowedPrefix($attribute)
            ) {
                unset($attributes[$key]);
                continue;
            }

            // Normalize attribute key, if needed
            if ($attribute != $key) {
                unset($attributes[$key]);
                $attributes[$attribute] = $value;
            }

            // Normalize boolean attribute values
            if (isset($this->booleanAttributes[$attribute])) {
                $attributes[$attribute] = $this->prepareBooleanAttributeValue($attribute, $value);
            }
        }

        return $attributes;
    }

    /**
     * Prepare a boolean attribute value
     *
     * Prepares the expected representation for the boolean attribute specified.
     *
     * @param  string $attribute
     * @param  mixed $value
     * @return string
     */
    protected function prepareBooleanAttributeValue($attribute, $value)
    {
        if (! is_bool($value) && in_array($value, $this->booleanAttributes[$attribute])) {
            return $value;
        }

        $value = (bool) $value;
        return ($value
            ? $this->booleanAttributes[$attribute]['on']
            : $this->booleanAttributes[$attribute]['off']
        );
    }

    /**
     * Translates the value of the HTML attribute if it should be translated and this view helper has a translator
     *
     * @param string $key
     * @param string $value
     *
     * @return string
     */
    protected function translateHtmlAttributeValue($key, $value)
    {
        if (empty($value) || ($this->getTranslator() === null)) {
            return $value;
        }

        if (isset($this->translatableAttributes[$key]) || isset(self::$defaultTranslatableHtmlAttributes[$key])) {
            return $this->getTranslator()->translate($value, $this->getTranslatorTextDomain());
        } else {
            foreach ($this->translatableAttributePrefixes as $prefix) {
                if (0 === mb_strpos($key, $prefix)) {
                    // prefix matches => return translated $value
                    return $this->getTranslator()->translate($value, $this->getTranslatorTextDomain());
                }
            }
            foreach (self::$defaultTranslatableHtmlAttributePrefixes as $prefix) {
                if (0 === mb_strpos($key, $prefix)) {
                    // default prefix matches => return translated $value
                    return $this->getTranslator()->translate($value, $this->getTranslatorTextDomain());
                }
            }
        }

        return $value;
    }

    /**
     * Adds an HTML attribute to the list of valid attributes
     *
     * @param string $attribute
     * @return AbstractHelper
     * @throws InvalidArgumentException for attribute names that are invalid
     *     per the HTML specifications.
     */
    public function addValidAttribute($attribute)
    {
        if (! $this->isValidAttributeName($attribute)) {
            throw new InvalidArgumentException(sprintf('%s is not a valid attribute name', $attribute));
        }

        $this->validTagAttributes[$attribute] = true;
        return $this;
    }

    /**
     * Adds a prefix to the list of valid attribute prefixes
     *
     * @param string $prefix
     * @return AbstractHelper
     * @throws InvalidArgumentException for attribute prefixes that are invalid
     *     per the HTML specifications for attribute names.
     */
    public function addValidAttributePrefix($prefix)
    {
        if (! $this->isValidAttributeName($prefix)) {
            throw new InvalidArgumentException(sprintf('%s is not a valid attribute prefix', $prefix));
        }

        $this->validTagAttributePrefixes[] = $prefix;
        return $this;
    }

    /**
     * Adds an HTML attribute to the list of translatable attributes
     *
     * @param string $attribute
     *
     * @return AbstractHelper
     */
    public function addTranslatableAttribute($attribute)
    {
        $this->translatableAttributes[$attribute] = true;

        return $this;
    }

    /**
     * Adds an HTML attribute to the list of the default translatable attributes
     *
     * @param string $attribute
     */
    public static function addDefaultTranslatableAttribute($attribute)
    {
        self::$defaultTranslatableHtmlAttributes[$attribute] = true;
    }

    /**
     * Adds an HTML attribute to the list of translatable attributes
     *
     * @param string $prefix
     *
     * @return AbstractHelper
     */
    public function addTranslatableAttributePrefix($prefix)
    {
        $this->translatableAttributePrefixes[] = $prefix;

        return $this;
    }

    /**
     * Adds an HTML attribute to the list of translatable attributes
     *
     * @param string $prefix
     */
    public static function addDefaultTranslatableAttributePrefix($prefix)
    {
        self::$defaultTranslatableHtmlAttributePrefixes[] = $prefix;
    }

    /**
     * Whether the passed attribute is valid or not
     *
     * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
     *     Description of valid attributes
     * @param string  $attribute
     * @return bool
     */
    protected function isValidAttributeName($attribute)
    {
        return preg_match('/^[^\t\n\f \/>"\'=]+$/', $attribute);
    }

    /**
     * Whether the passed attribute has a valid prefix or not
     *
     * @param string  $attribute
     * @return bool
     */
    protected function hasAllowedPrefix($attribute)
    {
        foreach ($this->validTagAttributePrefixes as $prefix) {
            if (substr($attribute, 0, strlen($prefix)) === $prefix) {
                return true;
            }
        }

        return false;
    }
}
