<?php

/**
 * @package Dotclear
 *
 * @copyright Olivier Meunier & Association Dotclear
 * @copyright AGPL-3.0
 */
declare(strict_types=1);

namespace Dotclear\Helper\Html\Form;

use Dotclear\App;

/**
 * @class Component
 * @brief HTML Forms creation helpers
 *
 * This class describes a component.
 *
 * @method      $this accesskey(string $accesskey)
 * @method      $this autocapitalize(string $autocapitalize)
 * @method      $this autocomplete(string $autocomplete)
 * @method      $this autocorrect(string $autocorrect)
 * @method      $this autofocus(bool $autofocus)
 * @method      $this checked(bool $checked)
 * @method      $this class(string|array<string> $class)
 * @method      $this contenteditable(bool $contenteditable)
 * @method      $this default(null|string|int|float $default)
 * @method      $this data(array<string, string> $data)
 * @method      $this dir(string $dir)
 * @method      $this disabled(bool $disabled)
 * @method      $this enterkeyhint(string $enterkeyhint)
 * @method      $this extra(string|array<string> $extra)
 * @method      $this form(string $form)
 * @method      $this id(string $id)
 * @method      $this inert(bool $inert)
 * @method      $this inputmode(string $inputmode)
 * @method      $this label(Label $label)
 * @method      $this lang(string $lang)
 * @method      $this list(string $list)
 * @method      $this max(null|int|float|string $max)
 * @method      $this maxlength(int $maxlength)
 * @method      $this min(null|int|float|string $min)
 * @method      $this name(string $name)
 * @method      $this pattern(string $pattern)
 * @method      $this placeholder(string $placeholder)
 * @method      $this popover(bool|'hint'|'auto'|'manual' $popover)
 * @method      $this readonly(bool $readonly)
 * @method      $this required(bool $required)
 * @method      $this role(string $role)
 * @method      $this size(int $size)
 * @method      $this spellcheck(bool $spellcheck)
 * @method      $this step(string $step)
 * @method      $this tabindex(int $tabindex)
 * @method      $this title(string $title)
 * @method      $this translate(bool $translate)
 * @method      $this type(string $type)
 * @method      $this value(string|int|float $value)
 * // ARIA attributes
 * @method      $this ariaCurrent(null|bool|'page'|'step'|'location'|'date'|'time' $ariaCurrent)
 * @method      $this ariaDescribedby(?string $ariaDescribedby)
 * @method      $this ariaDetails(?string $ariaDetails)
 * @method      $this ariaDisabled(bool $ariaDisabled)
 * @method      $this ariaHidden(bool $ariaHidden)
 * @method      $this ariaLabel(?string $ariaLabel)
 * @method      $this ariaLabelledby(?string $ariaLabelledby)
 * // Used only for components with childs (items and/or fields for form and fieldset):
 * @method      $this fields(Iterable<Component> $fields)
 * @method      $this items(Iterable<Component> $items)
 * @method      $this format(string $format)
 * @method      $this separator(string $separator)
 *
 * @property    ?string $accesskey
 * @property    ?string $autocapitalize
 * @property    ?string $autocomplete
 * @property    ?string $autocorrect
 * @property    ?bool $autofocus
 * @property    ?bool $checked
 * @property    null|string|array<string> $class
 * @property    ?bool $contenteditable
 * @property    null|array<string, string> $data
 * @property    null|string|int|float $default
 * @property    ?string $dir
 * @property    ?bool $disabled
 * @property    ?string $enterkeyhint
 * @property    null|string|array<string> $extra
 * @property    ?string $form
 * @property    ?string $id
 * @property    ?bool $inert
 * @property    ?string $inputmode
 * @property    ?Label $label
 * @property    ?string $lang
 * @property    ?string $list
 * @property    null|int|float|string $max
 * @property    ?int $maxlength
 * @property    null|int|float|string $min
 * @property    ?string $name
 * @property    ?string $pattern
 * @property    ?string $placeholder
 * @property    null|'hint'|'auto'|'manual'|bool $popover
 * @property    ?bool $readonly
 * @property    ?bool $required
 * @property    ?string $role
 * @property    ?int $size
 * @property    ?bool $spellcheck
 * @property    ?string $step
 * @property    ?int $tabindex
 * @property    ?string $title
 * @property    ?bool $translate
 * @property    ?string $type
 * @property    null|string|int|float $value
 * // ARIA attributes
 * @property    null|bool|'page'|'step'|'location'|'date'|'time' $ariaCurrent
 * @property    ?string $ariaDescribedby
 * @property    ?string $ariaDetails
 * @property    ?bool $ariaDisabled
 * @property    ?bool $ariaHidden
 * @property    ?string $ariaLabel
 * @property    ?string $ariaLabelledby
 * // Used only for components with childs (items and/or fields for form and fieldset):
 * @property    null|Iterable<Component> $fields Form or Fieldset component only
 * @property    null|Iterable<Component> $items
 * @property    ?string $format
 * @property    ?string $separator
 */
abstract class Component
{
    /**
     * Custom component properties (see __get() and __set())
     *
     * @var array<array-key, mixed>     $properties
     */
    protected array $properties = [];

    /**
     * May be use to display development and debug information (disabled in CLI mode)
     */
    protected bool $renderVerbose;

    /**
     * Constructs a new instance.
     *
     * @param      null|class-string<Component>     $componentClass     The component class
     * @param      null|string                      $htmlElement        The html element (will be used to render component)
     */
    public function __construct(
        private ?string $componentClass = null,
        private ?string $htmlElement = null
    ) {
        $this->componentClass ??= self::class;

        $this->renderVerbose = !App::config()->cliMode() && App::config()->devMode() && App::config()->debugMode();
    }

    /**
     * Get current value of renderVerbose property
     */
    public function isVerbose(): bool
    {
        return $this->renderVerbose;
    }

    /**
     * Call statically new instance
     *
     * Use formXxx::init(...$args) to statically create a new instance
     *
     * @param     mixed   ...$args The arguments
     *
     * @return object New formXxx instance
     */
    public static function init(...$args)
    {
        $class = static::class;

        return new $class(...$args);
    }

    /**
     * Magic getter method
     *
     * @param      string  $property  The property
     *
     * @return     mixed   property value if property exists or null
     */
    public function __get(string $property): mixed
    {
        return $this->properties[$property] ?? null;
    }

    /**
     * Magic setter method
     *
     * @param      string  $property  The property
     * @param      mixed   $value     The value
     *
     * @return     self
     */
    public function __set(string $property, mixed $value)
    {
        $this->properties[$property] = $value;

        // @phpstan-ignore return.void
        return $this;
    }

    /**
     * Magic isset method
     *
     * @param      string  $property  The property
     */
    public function __isset(string $property): bool
    {
        return isset($this->properties[$property]);
    }

    /**
     * Magic unset method
     *
     * @param      string  $property  The property
     */
    public function __unset(string $property): void
    {
        unset($this->properties[$property]);
    }

    /**
     * Magic call method
     *
     * If there is no argument ($argument empty array), assume that it's a get
     * If not, assume that's is a set (value = $argument[0])
     *
     * @param      string           $method     The property
     * @param      array<mixed>     $arguments  The arguments
     *
     * @return     mixed   property value (or null), self
     */
    public function __call(string $method, array $arguments)
    {
        if ($arguments === []) {
            // No argument, assume its a get
            if (array_key_exists($method, $this->properties)) {
                return $this->properties[$method];
            }

            return null;
        }

        // Argument here, assume its a set
        $this->properties[$method] = $arguments[0];

        return $this;
    }

    /**
     * Magic invoke method
     *
     * Return rendering of component
     */
    public function __invoke(): string
    {
        return $this->render();
    }

    /**
     * Gets the type of component
     *
     * @return     string  The type.
     */
    public function getType(): string
    {
        return $this->componentClass ?? self::class;
    }

    /**
     * Sets the type of component
     *
     * @param      class-string<Component>  $type   The type (class name)
     *
     * @return static    self instance, enabling to chain calls
     */
    public function setType(string $type): static
    {
        $this->componentClass = $type;

        return $this;
    }

    /**
     * Gets the HTML element
     *
     * @return     null|string  The element.
     */
    public function getElement(): ?string
    {
        return $this->htmlElement;
    }

    /**
     * Sets the HTML element
     *
     * @param      string  $element  The element
     *
     * @return static    self instance, enabling to chain calls
     */
    public function setElement(string $element): static
    {
        $this->htmlElement = $element;

        return $this;
    }

    /**
     * Attaches the label.
     *
     * @param      Label|null      $label     The label
     * @param      int|null        $position  The position
     *
     * @return static    self instance, enabling to chain calls
     */
    public function attachLabel(?Label $label = null, ?int $position = null): static
    {
        if ($label instanceof Label) {
            $this->label($label);
            $label->for($this->id ?? '');
            if ($position !== null) {
                $label->setPosition($position);
            }
        } elseif ($this->label !== null) {
            unset($this->label);
        }

        return $this;
    }

    /**
     * Detaches the label from this component
     *
     * @return static    self instance, enabling to chain calls
     */
    public function detachLabel(): static
    {
        if ($this->label !== null) {
            unset($this->label);
        }

        return $this;
    }

    /**
     * Sets the identifier (name/id).
     *
     * If the given identifier is a string, set name = id = given string
     * If it is an array of only one element, name = [first element]
     * Else name = [first element], id = [second element]
     *
     * A name attribute will be set only on a specific list of HTML element (see list below).
     * You may bypass this by using ->name($name) method.
     *
     * @param      string|list{0: string, 1?: string}|null $identifier (string or array)
     *
     * @return static    self instance, enabling to chain calls
     */
    public function setIdentifier(string|array|null $identifier): static
    {
        $nameable = [
            'button',
            'fieldset',
            'form',
            'iframe',
            'input',
            'map',
            'meta',
            'object',
            'output',
            'param',
            'select',
            'textarea',
        ];

        $element = mb_strtolower($this->htmlElement ?? '');
        if (is_array($identifier)) {
            if (in_array($element, $nameable)) {
                $this->name = (string) $identifier[0];
            }

            if (isset($identifier[1])) {
                $this->id = $identifier[1];
            }
        } elseif (!is_null($identifier)) {
            if (in_array($element, $nameable)) {
                $this->name = $identifier;
            }

            $this->id = $identifier;
        }

        return $this;
    }

    /**
     * Check mandatory attributes in properties, at least name or id must be present
     */
    public function checkMandatoryAttributes(): bool
    {
        // Check for mandatory info
        return $this->name !== null || $this->id !== null;
    }

    /**
     * Render common attributes
     *
     *      $this->
     *
     *          type            => string type (may be used for input component).
     *
     *          name            => string name (required if id is not provided).
     *          id              => string id (required if name is not provided).
     *
     *          value           => string|int|float value.
     *          default         => null|string|int|float default value (will be used if value is not provided).
     *          checked         => boolean checked.
     *
     *          accesskey       => string accesskey (character(s) space separated).
     *          autocapitalize  => string autocapitalyze mode.
     *          autocomplete    => string autocomplete type.
     *          autocorrect     => string autocorrect mode.
     *          autofocus       => boolean autofocus.
     *          class           => string (or array of string) class(es).
     *          contenteditable => boolean content editable.
     *          dir             => string direction.
     *          disabled        => boolean disabled.
     *          enterkeyhint    => string enter key hint.
     *          form            => string form id.
     *          inert           => boolean inert.
     *          inputmode       => string inputmode.
     *          lang            => string lang.
     *          list            => string list id.
     *          max             => int|float max value.
     *          maxlength       => int max length.
     *          min             => int|float min value.
     *          readonly        => bool readonly.
     *          required        => bool required.
     *          role            => string role.
     *          pattern         => string pattern.
     *          placeholder     => string placeholder.
     *          popover         => 'hint'|'auto'|'manual'|bool popover.
     *          size            => int size.
     *          spellcheck      => boolean spellcheck.
     *          step            => string step.
     *          tabindex        => int tabindex.
     *          title           => string title.
     *          translate       => no if false, yes if true
     *
     *          data            => array data.
     *              [
     *                  key   => string data id (rendered as data-<id>).
     *                  value => string data value.
     *              ]
     *
     *          extra           => string (or array of string) extra HTML attributes.
     *
     *          aria-current     => bool|'page'|'step'|'location'|'date'|'time'
     *          aria-describedby => string
     *          aria-details     => string
     *          aria-disabled    => bool
     *          aria-hidden      => bool
     *          aria-label       => string
     *          aria-labelledby  => string
     *
     * @param      bool    $includeValue    Includes $this->value if exist (default = true)
     *                                      should be set to false to textarea and may be some others
     */
    public function renderCommonAttributes(bool $includeValue = true): string
    {
        // Sanitize some attributes
        $doUnsetEmpties = function (string $propertyName): void {
            if (is_array($this->{$propertyName})) {
                // Remove empty items in array
                $this->{$propertyName} = array_filter($this->{$propertyName});
            }

            if ($this->{$propertyName} === '' || $this->{$propertyName} === []) {
                // Unset empty property
                $this->{$propertyName} = null;
            }
        };
        $doUnsetEmpties('class');
        $doUnsetEmpties('extra');

        //Render attributes
        $render = '' .

            // Type (used for input component)
            ($this->type !== null ?
                ' type="' . $this->type . '"' : '') .

            // Identifier
            ($this->name !== null ?
                 ' name="' . $this->name . '"' : '') .
            ($this->id !== null ?
                ' id="' . $this->id . '"' : '') .

            // Value
            // - $this->default will be used as value if exists and $this->value does not
            ($includeValue && array_key_exists('value', $this->properties) ?
                ' value="' . $this->value . '"' : '') .
            ($includeValue && !array_key_exists('value', $this->properties) && array_key_exists('default', $this->properties) ?
                ' value="' . $this->default . '"' : '') .
            ($this->checked !== null && $this->checked ?
                ' checked' : '') .

            // Common attributes
            ($this->accesskey !== null ?
                ' accesskey="' . $this->accesskey . '"' : '') .
            ($this->autocapitalize !== null ?
                ' autocapitalize="' . $this->autocapitalize . '"' : '') .
            ($this->autocomplete !== null ?
                ' autocomplete="' . $this->autocomplete . '"' : '') .
            ($this->autocorrect !== null ?
                ' autocorrect="' . $this->autocorrect . '"' : '') .
            ($this->autofocus !== null && $this->autofocus ?
                ' autofocus' : '') .
            ($this->class !== null ?
                ' class="' . (is_array($this->class) ? implode(' ', array_unique($this->class)) : $this->class) . '"' : '') .
            ($this->contenteditable !== null && $this->contenteditable ?
                ' contenteditable' : '') .
            ($this->dir !== null ?
                ' dir="' . $this->dir . '"' : '') .
            ($this->disabled !== null && $this->disabled ?
                ' disabled' : '') .
            ($this->enterkeyhint !== null ?
                ' enterkeyhint="' . $this->enterkeyhint . '"' : '') .
            ($this->form !== null ?
                ' form="' . $this->form . '"' : '') .
            ($this->inert !== null && $this->inert ?
                ' inert' : '') .
            ($this->inputmode !== null ?
                ' inputmode="' . $this->inputmode . '"' : '') .
            ($this->lang !== null ?
                ' lang="' . $this->lang . '"' : '') .
            ($this->list !== null ?
                ' list="' . $this->list . '"' : '') .
            ($this->max !== null ?
                ' max="' . strval($this->max) . '"' : '') .
            ($this->maxlength !== null ?
                ' maxlength="' . strval((int) $this->maxlength) . '"' : '') .
            ($this->min !== null ?
                ' min="' . strval($this->min) . '"' : '') .
            ($this->pattern !== null ?
                ' pattern="' . $this->pattern . '"' : '') .
            ($this->placeholder !== null ?
                ' placeholder="' . $this->placeholder . '"' : '') .
            ($this->popover !== null && $this->popover ?
                ' popover' . (is_string($this->popover) ? '="' . $this->popover . '"' : '') : '') .
            ($this->readonly !== null && $this->readonly ?
                ' readonly' : '') .
            ($this->required !== null && $this->required ?
                ' required' : '') .
            ($this->role !== null && $this->role ?
                ' role="' . $this->role . '"' : '') .
            ($this->size !== null ?
                ' size="' . strval((int) $this->size) . '"' : '') .
            ($this->spellcheck !== null ?
                ' spellcheck="' . ($this->spellcheck ? 'true' : 'false') . '"' : '') .
            ($this->step !== null ?
                ' step="' . $this->step . '"' : '') .
            ($this->tabindex !== null ?
                ' tabindex="' . strval((int) $this->tabindex) . '"' : '') .
            ($this->title !== null ?
                ' title="' . $this->title . '"' : '') .
            ($this->translate !== null ?
                ' translate="' . ($this->translate ? 'yes' : 'no') . '"' : '') .

        '';

        if ($this->data !== null) {
            // Data attributes
            foreach ($this->data as $key => $value) {
                $render .= ' data-' . $key . '="' . $value . '"';
            }
        }

        if ($this->extra !== null) {
            // Extra HTML
            $render .= ' ' . (is_array($this->extra) ? implode(' ', $this->extra) : $this->extra);
        }

        // ARIA
        $render .= '' .

            ($this->ariaCurrent !== null && $this->ariaCurrent ?
                ' aria-current="' . $this->ariaCurrent . '"' : '') .
            ($this->ariaDescribedby !== null ?
                ' aria-describedby="' . $this->ariaDescribedby . '"' : '') .
            ($this->ariaDetails !== null ?
                ' aria-details="' . $this->ariaDetails . '"' : '') .
            ($this->ariaDisabled !== null && $this->ariaDisabled ?
                ' aria-disabled="true"' : '') .
            ($this->ariaHidden !== null && $this->ariaHidden ?
                ' aria-hidden="true"' : '') .
            ($this->ariaLabel !== null ?
                ' aria-label="' . $this->ariaLabel . '"' : '') .
            ($this->ariaLabelledby !== null ?
                ' aria-labelledby="' . $this->ariaLabelledby . '"' : '') .

        '';

        return $render;
    }

    /**
     * Render childs of a Component
     *
     * @param      null|Iterable<Component>     $childs  Childs to render
     * @param      null|string                  $format  The sprintf pattern to render item
     * @param      null|string                  $ignore  The type of child to ignore if present
     * @param      bool                         $inline  Keep all items inline
     */
    protected function renderChilds(?iterable $childs, ?string $format, ?string $ignore = null, bool $inline = false): string
    {
        $buffer = '';

        if ($childs !== null) {
            $first = true;
            $format ??= ($this->format ?? '%s');

            foreach ($childs as $child) {
                if ($child instanceof None) {
                    continue;
                }

                if ($ignore !== null && $child->getDefaultElement() === $ignore) {
                    continue;
                }

                if (!$first && $this->separator) {
                    $buffer .= $this->separator;
                }

                $buffer .= sprintf($format, $inline ? rtrim($child->render(), "\n") : $child->render());
                $first = false;
            }
        }

        return $buffer;
    }

    /**
     * Render items of a Component
     *
     * @param      null|string  $format  The sprintf pattern to render item
     * @param      null|string  $ignore  The type of item to ignore if present
     * @param      bool         $inline  Keep all items inline (removing ending newline from each rendered items)
     */
    public function renderItems(?string $format = null, ?string $ignore = null, bool $inline = false): string
    {
        return $this->renderChilds($this->items, $format, $ignore, $inline);
    }

    /**
     * Render items of a Component
     *
     * @param      null|string  $format  The sprintf pattern to render item
     * @param      null|string  $ignore  The type of field to ignore if present
     * @param      bool         $inline  Keep all items inline (removing ending newline from each rendered items)
     */
    public function renderFields(?string $format = null, ?string $ignore = null, bool $inline = false): string
    {
        return $this->renderChilds($this->fields, $format, $ignore, $inline);
    }

    // Abstract methods

    /**
     * Renders the object.
     *
     * Must be provided by classes which extends this class
     */
    abstract public function render(): string;

    /**
     * Gets the default element.
     *
     * @return     string  The default HTML element.
     */
    abstract public function getDefaultElement(): string;
}
