<?php

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

namespace Dotclear\Plugin\widgets;

use ArrayObject;
use Dotclear\App;
use Dotclear\Helper\Html\Form\Checkbox;
use Dotclear\Helper\Html\Form\Color;
use Dotclear\Helper\Html\Form\Div;
use Dotclear\Helper\Html\Form\Email;
use Dotclear\Helper\Html\Form\Hidden;
use Dotclear\Helper\Html\Form\Input;
use Dotclear\Helper\Html\Form\Label;
use Dotclear\Helper\Html\Form\None;
use Dotclear\Helper\Html\Form\Note;
use Dotclear\Helper\Html\Form\Number;
use Dotclear\Helper\Html\Form\Para;
use Dotclear\Helper\Html\Form\Radio;
use Dotclear\Helper\Html\Form\Select;
use Dotclear\Helper\Html\Form\Set;
use Dotclear\Helper\Html\Form\Text;
use Dotclear\Helper\Html\Form\Textarea;
use Dotclear\Helper\Html\Html;

/**
 * @brief   The widgets element handler.
 * @ingroup widgets
 *
 * Common widget properties
 *
 * @property    string $class
 * @property    bool   $content_only
 * @property    int    $homeonly
 * @property    bool   $offline
 * @property    string $title
 */
#[\AllowDynamicProperties]
class WidgetsElement
{
    // Constants

    /**
     * Widget displayed on every page.
     *
     * @var     int     ALL_PAGES
     */
    public const ALL_PAGES = 0;

    /**
     * Widget displayed on home page only.
     *
     * @var     int     HOME_ONLY
     */
    public const HOME_ONLY = 1;

    /**
     * Widget displayed on every page but home page.
     *
     * @var     int     EXCEPT_HOME
     */
    public const EXCEPT_HOME = 2;

    /**
     * Widget callback.
     *
     * @var     null|callable(WidgetsElement,int=):string   $public_callback
     */
    private $public_callback;

    /**
     * Widget append callback.
     *
     * @var     null|callable(WidgetsElement):void   $append_callback
     */
    public $append_callback;

    /**
     * Widget settings.
     *
     * @var     array<string, array{title:string, type:string, value:mixed, options?:mixed, opts?:mixed}>   $settings
     */
    protected array $settings;

    /**
     * Constructs a new instance.
     *
     * @param   string                                      $id         The widget ID
     * @param   string                                      $name       The widget name
     * @param   null|callable(WidgetsElement,int=):string   $callback   The widget callback
     * @param   string                                      $desc       The widget description
     * @param   string                                      $plugin_id  The module ID providing this widget
     */
    public function __construct(
        private readonly string $id,
        private readonly string $name,
        ?callable $callback,
        private readonly string $desc = '',
        private readonly string $plugin_id = '',
    ) {
        if (!is_callable($callback)) {
            $widget = new ArrayObject(['id' => $id, 'callback' => $callback]);
            # --BEHAVIOR-- widgetGetCallback -- ArrayObject
            App::behavior()->callBehavior('widgetGetCallback', $widget);
            $callback = is_callable($widget['callback']) ? $widget['callback'] : null;
        }

        $this->public_callback = $callback;
    }

    /**
     * Get array of widget settings
     *
     * @param   int     $order  The order
     *
     * @return  array<string, mixed>
     */
    public function serialize(int $order): array
    {
        $values = [];
        foreach ($this->settings as $k => $v) {
            $values[$k] = $v['value'];
        }

        $values['id']    = $this->id;
        $values['order'] = $order;

        return $values;
    }

    /**
     * Get widget ID.
     */
    public function id(): string
    {
        return $this->id;
    }

    /**
     * Get widget name.
     */
    public function name(): string
    {
        return $this->name;
    }

    /**
     * Get widget description.
     */
    public function desc(): string
    {
        return $this->desc;
    }

    /**
     * Get widget plugin ID creator.
     */
    public function pluginID(): string
    {
        return $this->plugin_id;
    }

    /**
     * Gets the widget callback.
     */
    public function getCallback(): ?callable
    {
        return $this->public_callback;
    }

    /**
     * Call a widget callback.
     *
     * @param   ?int   $index   Widget index in stack
     *
     * @return  string
     */
    public function call(?int $index = 0)
    {
        if (!is_null($this->public_callback)) {
            return call_user_func($this->public_callback, $this, (int) $index);
        }

        return (new Note())->text('Callback not found for widget ' . $this->id)->render();
    }

    /**
     * Widget rendering tool.
     *
     * @param   bool    $content_only   The content only
     * @param   string  $class          The class
     * @param   string  $attr           The attribute
     * @param   string  $content        The content
     * @param   string  $block          The block to use to encapsulate content (default = div)
     */
    public function renderDiv(bool $content_only, string $class, string $attr, string $content, ?string $block = 'div'): string
    {
        if ($content_only) {
            return $content;
        }

        // Add widget class to others classes given
        $classes = [
            'widget',
            ... array_filter(explode(' ', $class)),
        ];

        $theme = App::blog()->settings()->get('system')->getStr('theme', false);
        if ($theme !== '') {
            /*
             * widgetcontainerformat, if defined for the theme in his _define.php,
             * is a sprintf string format in which:
             * - %1$s is the class(es) affected to the container
             * - %2$s is the additional attributes affected to the container
             * - %3$s is the content of the widget
             * - %4$s is the html element to use as container
             *
             * Don't forget to set widgettitleformat and widgetsubtitleformat if necessary (see default rendering in methods below)
            */
            $wtscheme = is_string($wtscheme = App::themes()->moduleInfo($theme, 'widgetcontainerformat')) ? $wtscheme : '';
            if ($wtscheme !== '') {
                return sprintf($wtscheme . "\n", Html::escapeHTML(implode(' ', $classes)), $attr, $content, $block);
            }
        }

        return (new Div(null, $block))
            ->class($classes)
            ->extra($attr)
            ->items([
                (new Text(null, $content)),
            ])
        ->render();
    }

    /**
     * Get format to use to render a title (or a subtitle) in a widget
     *
     * @param  bool   $subtitle True if it is a subtitle (default = false)
     */
    protected function getTitleFormat(bool $subtitle = false): string
    {
        $format = '<h%1$d>%%s</h%1$d>';

        $theme = App::blog()->settings()->get('system')->getStr('theme', false);
        if ($theme === '') {
            return '';
        }

        return sprintf($format, match (App::themes()->moduleInfo($theme, 'tplset')) {
            // Template set name => $subtitle ? H level for subtitle : H level for title
            'glaz'   => $subtitle ? 4 : 3,
            'dotty'  => $subtitle ? 4 : 3,
            'mustek' => $subtitle ? 3 : 2,
            default  => $subtitle ? 3 : 2,
        });
    }

    /**
     * Render widget title.
     *
     * @param   null|string     $title  The title
     */
    public function renderTitle(?string $title): string
    {
        if (!$title) {
            return '';
        }

        $theme = App::blog()->settings()->get('system')->getStr('theme', false);
        if ($theme === '') {
            return '';
        }

        $format = is_string($format = App::themes()->moduleInfo($theme, 'widgettitleformat')) ? $format : '';
        if ($format === '') {
            $format = $this->getTitleFormat();
        }

        return sprintf($format, $title);
    }

    /**
     * Render widget subtitle.
     *
     * @param   null|string     $title      The title
     * @param   bool            $render     The render
     */
    public function renderSubtitle(?string $title, $render = true): string
    {
        if (!$title && $render) {
            return '';
        }

        $theme = App::blog()->settings()->get('system')->getStr('theme', false);
        if ($theme === '') {
            return '';
        }

        $format = is_string($format = App::themes()->moduleInfo($theme, 'widgetsubtitleformat')) ? $format : '';
        if ($format === '') {
            $format = $this->getTitleFormat();
        }

        return sprintf($format, $title);
    }

    // Widget settings

    /**
     * Gets the specified setting value.
     *
     * @param   string  $n  The setting name
     */
    public function __get(string $n): mixed
    {
        return $this->get($n);
    }

    /**
     * Gets the specified setting value.
     *
     * @param   string  $n  The setting name
     */
    public function get(string $n): mixed
    {
        if (isset($this->settings[$n])) {
            return $this->settings[$n]['value'];
        }

        return null;
    }

    /**
     * Set the specified setting value.
     *
     * @param   string  $n  The setting name
     * @param   mixed   $v  The new value
     */
    public function __set(string $n, mixed $v)
    {
        $this->set($n, $v);
    }

    /**
     * Set the specified setting value.
     *
     * @param   string  $n  The setting name
     * @param   mixed   $v  The new value
     */
    public function set(string $n, mixed $v): void
    {
        if (isset($this->settings[$n])) {
            $this->settings[$n]['value'] = $v;
        }
    }

    /**
     * Store a setting.
     *
     * @param   string  $name   The name
     * @param   string  $title  The title
     * @param   mixed   $value  The value
     * @param   string  $type   The type
     */
    public function setting(string $name, string $title, $value, string $type = 'text'): self
    {
        $types = [
            // type (string) => list of items may be provided (boolean)
            'text'     => false,
            'textarea' => false,
            'check'    => false,
            'radio'    => true,
            'combo'    => true,
            'color'    => false,
            'email'    => false,
            'number'   => false,
        ];

        if (!array_key_exists($type, $types)) {
            return $this;
        }

        $index = 4; // 1st optional argument (after type)

        if ($types[$type] && func_num_args() > $index) {
            $options = func_get_arg($index);
            if (!is_array($options)) {
                return $this;
            }

            $index++;
        }

        // If any, the last argument should be an array (key → value) of opts
        if (func_num_args() > $index) {
            $opts = func_get_arg($index);
        }

        $this->settings[$name] = [
            'title' => $title,
            'type'  => $type,
            'value' => $value,
        ];

        if (isset($options)) {
            $this->settings[$name]['options'] = $options;
        }

        if (isset($opts)) {
            $this->settings[$name]['opts'] = $opts;
        }

        return $this;
    }

    /**
     * Get widget settings.
     *
     * @return  array<string, mixed>
     */
    public function settings(): array
    {
        return $this->settings;
    }

    /**
     * Get widget settings form.
     *
     * @param   string  $pr     The prefix
     * @param   int     $i      The index
     */
    public function formSettings(string $pr = '', int &$i = 0): string
    {
        $res = '';
        foreach ($this->settings as $id => $s) {
            $res .= $this->formSetting($id, $s, $pr, $i)->render();
            $i++;
        }

        return $res;
    }

    /**
     * Get a widget setting field.
     *
     * @param   string                  $id     The identifier
     * @param   array<string, mixed>    $s      The setting
     * @param   string                  $pr     The prefix
     * @param   int                     $i      The index
     */
    public function formSetting(string $id, array $s, string $pr = '', int &$i = 0): Set
    {
        $wfid  = 'wf-' . $i;
        $iname = $pr !== '' ? $pr . '[' . $id . ']' : $id;
        $class = isset($s['opts'])
                && is_array($s['opts'])
                && isset($s['opts']['class'])
                && is_string($s['opts']['class'])
            ? ' ' . $s['opts']['class']
            : '';

        $user_lang = is_string($user_lang = App::auth()->getInfo('user_lang')) ? $user_lang : '';

        $setting = new None();

        switch ($s['type']) {
            case 'text':
                $value = is_string($value = $s['value']) ? $value : '';
                $title = is_string($title = $s['title']) ? $title : '';

                $setting = (new Para())
                    ->items([
                        (new Input([$iname, $wfid]))
                            ->size(20)
                            ->maxlength(255)
                            ->value(Html::escapeHTML($value))
                            ->class(['maximal', $class])
                            ->lang($user_lang)
                            ->spellcheck(true)
                            ->label(new Label($title, Label::IL_TF)),
                    ]);

                break;
            case 'textarea':
                $value = is_string($value = $s['value']) ? $value : '';
                $title = is_string($title = $s['title']) ? $title : '';

                $setting = (new Para())
                    ->items([
                        (new Textarea([$iname, $wfid]))
                            ->rows(30)
                            ->cols(8)
                            ->value(Html::escapeHTML($value))
                            ->class(['maximal', $class])
                            ->lang($user_lang)
                            ->spellcheck(true)
                            ->label(new Label($title, Label::IL_TF)),
                        (new Note())
                            ->class('info')
                            ->text(__('Content can be enhanced using HTML syntax.')),
                    ]);

                break;
            case 'check':
                $title = is_string($title = $s['title']) ? $title : '';

                $setting = (new Para())
                    ->items([
                        (new Hidden([$iname], '0')),
                        (new Checkbox([$iname, $wfid], (bool) $s['value']))
                            ->value('1')
                            ->class($class)
                            ->label(new Label($title, Label::IL_FT)),
                    ]);

                break;
            case 'radio':
                $radios = [];
                if (!empty($s['options']) && is_array($s['options'])) {
                    foreach ($s['options'] as $k => $v) {
                        if (is_string($k) && is_array($v) && isset($v[1]) && is_scalar($v[1])) {
                            $radios[] = (new Radio([$iname, $wfid . '-' . $k], $s['value'] == $v[1]))
                                ->value((string) $v[1])
                                ->class($class)
                                ->label(new Label($k, Label::IL_FT));
                        }
                    }
                }

                $setting = (new Para())
                    ->items($radios);

                break;
            case 'combo':
                $value = is_string($value = $s['value']) ? $value : '';
                $title = is_string($title = $s['title']) ? $title : '';

                if (isset($s['options']) && is_array($s['options'])) {
                    /**
                     * @var array<array-key, string>
                     */
                    $options = $s['options'];

                    $setting = (new Para())
                        ->items([
                            (new Select([$iname, $wfid]))
                                ->items($options)
                                ->default($value)
                                ->class($class)
                                ->label(new Label($title, Label::IL_TF)),
                        ]);
                }

                break;
            case 'color':
                $value = is_string($value = $s['value']) ? $value : '';
                $title = is_string($title = $s['title']) ? $title : '';

                $setting = (new Para())
                    ->items([
                        (new Color([$iname, $wfid]))
                            ->value(Html::escapeHTML($value))
                            ->class($class)
                            ->label(new Label($title, Label::IL_TF)),
                    ]);

                break;
            case 'email':
                $value = is_string($value = $s['value']) ? $value : '';
                $title = is_string($title = $s['title']) ? $title : '';

                $setting = (new Para())
                    ->items([
                        (new Email([$iname, $wfid]))
                            ->value(Html::escapeHTML($value))
                            ->autocomplete('email')
                            ->class($class)
                            ->label(new Label($title, Label::IL_TF)),
                    ]);

                break;
            case 'number':
                $value = is_string($value = $s['value']) ? $value : '';
                $title = is_string($title = $s['title']) ? $title : '';

                $setting = (new Para())
                    ->items([
                        (new Number([$iname, $wfid]))
                            ->value($value)
                            ->class($class)
                            ->label(new Label($title, Label::IL_TF)),
                    ]);

                break;
            default:
                $setting = (new None());

                break;
        }

        return (new Set())
            ->items([$setting]);
    }

    // Widget helpers

    /**
     * Adds a title setting.
     *
     * @param   string  $title  The title
     */
    public function addTitle(string $title = ''): self
    {
        return $this->setting('title', __('Title (optional)') . ' :', $title);
    }

    /**
     * Adds a home only setting.
     *
     * @param   null|array<string, mixed>     $options
     */
    public function addHomeOnly(?array $options = null): self
    {
        $list = [
            __('All pages')           => self::ALL_PAGES,
            __('Home page only')      => self::HOME_ONLY,
            __('Except on home page') => self::EXCEPT_HOME, ];

        if ($options !== null) {
            $list = array_merge($list, $options);
        }

        return $this->setting(
            'homeonly',
            __('Display on:'),
            self::ALL_PAGES,
            'combo',
            $list
        );
    }

    /**
     * Check if the widget should be displayed, depending on its homeonly setting.
     *
     * @param   string  $type           The type
     * @param   int     $alt_not_home   Alternate not home test value
     * @param   int     $alt_home       Alternate home test value
     */
    public function checkHomeOnly(string $type, $alt_not_home = 1, $alt_home = 0): bool
    {
        return !(isset($this->settings['homeonly']) && isset($this->settings['homeonly']['value']) && ($this->settings['homeonly']['value'] == self::HOME_ONLY && !App::url()->isHome($type) && $alt_not_home || $this->settings['homeonly']['value'] == self::EXCEPT_HOME && (App::url()->isHome($type) || $alt_home)));
    }

    /**
     * Adds a not on archive setting.
     *
     * @param   int     $not_on_archive   The content only flag
     */
    public function addNotOnArchive(int $not_on_archive = 0): self
    {
        return $this->setting('not_on_archive', __('Do not display on archive main page'), $not_on_archive, 'check');
    }

    /**
     * Check if the widget should be displayed, depending on its not on archive setting.
     *
     * @param   string  $type           The type
     */
    public function checkNotOnArchive(string $type): bool
    {
        if ($type !== 'archive') {
            return true;
        }

        // On archive and widget should not be displayed on it
        return !isset($this->settings['not_on_archive']) || (bool) $this->settings['not_on_archive']['value'] === false;
    }

    /**
     * Adds a content only setting.
     *
     * @param   int     $content_only   The content only flag
     */
    public function addContentOnly(int $content_only = 0): self
    {
        return $this->setting('content_only', __('Content only'), $content_only, 'check');
    }

    /**
     * Adds a class setting.
     *
     * @param   string  $class  The class
     */
    public function addClass(string $class = ''): self
    {
        return $this->setting('class', __('CSS class:'), $class);
    }

    /**
     * Adds an offline setting.
     *
     * @param   int     $offline    The offline flag
     */
    public function addOffline(int $offline = 0): self
    {
        return $this->setting('offline', __('Offline'), $offline, 'check');
    }

    /**
     * Determines if setting is offline.
     */
    public function isOffline(): bool
    {
        return (bool) ($this->settings['offline']['value'] ?? false);
    }
}
