<?php

namespace dokuwiki\plugin\struct\types;

use dokuwiki\Extension\Plugin;
use dokuwiki\plugin\struct\meta\Column;
use dokuwiki\plugin\struct\meta\QueryBuilder;
use dokuwiki\plugin\struct\meta\QueryBuilderWhere;
use dokuwiki\plugin\struct\meta\StructException;
use dokuwiki\plugin\struct\meta\TranslationUtilities;
use dokuwiki\plugin\struct\meta\ValidationException;
use dokuwiki\plugin\struct\meta\Value;

/**
 * Class AbstractBaseType
 *
 * This class represents a basic type that can be configured to be used in a Schema. It is the main
 * part of a column definition as defined in meta\Column
 *
 * This defines also how the content of the coulmn will be entered and formatted.
 *
 * @package dokuwiki\plugin\struct\types
 * @see Column
 */
abstract class AbstractBaseType
{
    use TranslationUtilities;

    /**
     * @var array current config
     */
    protected $config = [];

    /**
     * @var array config keys that should not be cleaned despite not being in $config
     */
    protected $keepconfig = ['label', 'hint', 'visibility'];

    /**
     * @var string label for the field
     */
    protected $label = '';

    /**
     * @var bool is this a multivalue field?
     */
    protected $ismulti = false;

    /**
     * @var int the type ID
     */
    protected $tid = 0;

    /**
     * @var null|Column the column context this type is part of
     */
    protected $context;

    /**
     * @var Plugin
     */
    protected $hlp;

    /**
     * AbstractBaseType constructor.
     * @param array|null $config The configuration, might be null if nothing saved, yet
     * @param string $label The label for this field (empty for new definitions=
     * @param bool $ismulti Should this field accept multiple values?
     * @param int $tid The id of this type if it has been saved, yet
     */
    public function __construct($config = null, $label = '', $ismulti = false, $tid = 0)
    {
        // general config options
        $baseconfig = [
            'visibility' => [
                'inpage' => true,
                'ineditor' => true
            ]
        ];

        // use previously saved configuration, ignoring all keys that are not supposed to be here
        if (!is_null($config)) {
            $this->mergeConfig($config, $this->config);
        }

        $this->initTransConfig();
        $this->config = array_merge($baseconfig, $this->config);
        $this->label = $label;
        $this->ismulti = (bool)$ismulti;
        $this->tid = $tid;
    }

    /**
     * Merge the current config with the base config of the type
     *
     * Ignores all keys that are not supposed to be there. Recurses into sub keys
     *
     * @param array $current Current configuration
     * @param array $config Base Type configuration
     */
    protected function mergeConfig($current, &$config)
    {
        foreach ($current as $key => $value) {
            if (isset($config[$key]) || in_array($key, $this->keepconfig)) {
                if (isset($config[$key]) && is_array($config[$key])) {
                    $this->mergeConfig($value, $config[$key]);
                } else {
                    $config[$key] = $value;
                }
            }
        }
    }

    /**
     * Returns data as associative array
     *
     * @return array
     */
    public function getAsEntry()
    {
        return [
            'config' => json_encode($this->config, JSON_THROW_ON_ERROR),
            'label' => $this->label,
            'ismulti' => $this->ismulti,
            'class' => $this->getClass()
        ];
    }

    /**
     * The class name of this type (no namespace)
     * @return string
     */
    public function getClass()
    {
        $class = get_class($this);
        return substr($class, strrpos($class, "\\") + 1);
    }

    /**
     * Return the current configuration for this type
     *
     * @return array
     */
    public function getConfig()
    {
        return $this->config;
    }

    /**
     * @return boolean
     */
    public function isMulti()
    {
        return $this->ismulti;
    }

    /**
     * @return string
     */
    public function getLabel()
    {
        return $this->label;
    }

    /**
     * Returns the translated label for this type
     *
     * Uses the current language as determined by $conf['lang']. Falls back to english
     * and then to the type label
     *
     * @return string
     */
    public function getTranslatedLabel()
    {
        return $this->getTranslatedKey('label', $this->label);
    }

    /**
     * Returns the translated hint for this type
     *
     * Uses the current language as determined by $conf['lang']. Falls back to english.
     * Returns empty string if no hint is configured
     *
     * @return string
     */
    public function getTranslatedHint()
    {
        return $this->getTranslatedKey('hint', '');
    }

    /**
     * @return int
     */
    public function getTid()
    {
        return $this->tid;
    }

    /**
     * @return Column
     * @throws StructException
     */
    public function getContext()
    {
        if (is_null($this->context)) {
            throw new StructException(
                'Empty column context requested. Type was probably initialized outside of Schema.'
            );
        }
        return $this->context;
    }

    /**
     * @param Column $context
     */
    public function setContext($context)
    {
        $this->context = $context;
    }

    /**
     * @return bool
     */
    public function isVisibleInEditor()
    {
        return $this->config['visibility']['ineditor'];
    }

    /**
     * @return bool
     */
    public function isVisibleInPage()
    {
        return $this->config['visibility']['inpage'];
    }

    /**
     * Split a single value into multiple values
     *
     * This function is called on saving data when only a single value instead of an array
     * was submitted.
     *
     * Types implementing their own @param string $value
     * @return array
     * @see multiValueEditor() will probably want to override this
     *
     */
    public function splitValues($value)
    {
        return array_map('trim', explode(',', $value));
    }

    /**
     * Return the editor to edit multiple values
     *
     * Types can override this to provide a better alternative than multiple entry fields
     *
     * @param string $name the form base name where this has to be stored
     * @param string[] $rawvalues the current values
     * @param string $htmlID a unique id to be referenced by the label
     * @return string html
     */
    public function multiValueEditor($name, $rawvalues, $htmlID)
    {
        $html = '';
        foreach ($rawvalues as $value) {
            $html .= '<div class="multiwrap">';
            $html .= $this->valueEditor($name . '[]', $value, '');
            $html .= '</div>';
        }
        // empty field to add
        $html .= '<div class="newtemplate">';
        $html .= '<div class="multiwrap">';
        $html .= $this->valueEditor($name . '[]', '', $htmlID);
        $html .= '</div>';
        $html .= '</div>';

        return $html;
    }

    /**
     * Return the editor to edit a single value
     *
     * @param string $name the form name where this has to be stored
     * @param string $rawvalue the current value
     * @param string $htmlID a unique id to be referenced by the label
     *
     * @return string html
     */
    public function valueEditor($name, $rawvalue, $htmlID)
    {
        $class = 'struct_' . strtolower($this->getClass());

        // support the autocomplete configurations out of the box
        if (isset($this->config['autocomplete']['maxresult']) && $this->config['autocomplete']['maxresult']) {
            $class .= ' struct_autocomplete';
        }

        $params = [
            'name' => $name,
            'value' => $rawvalue,
            'class' => $class,
            'id' => $htmlID
        ];
        $attributes = buildAttributes($params, true);
        return "<input $attributes>";
    }

    /**
     * Output the stored data
     *
     * @param string|int $value the value stored in the database
     * @param \Doku_Renderer $R the renderer currently used to render the data
     * @param string $mode The mode the output is rendered in (eg. XHTML)
     * @return bool true if $mode could be satisfied
     */
    public function renderValue($value, \Doku_Renderer $R, $mode)
    {
        $value = $this->displayValue($value);
        $R->cdata($value);
        return true;
    }

    /**
     * format and return the data
     *
     * @param int[]|string[] $values the values stored in the database
     * @param \Doku_Renderer $R the renderer currently used to render the data
     * @param string $mode The mode the output is rendered in (eg. XHTML)
     * @return bool true if $mode could be satisfied
     */
    public function renderMultiValue($values, \Doku_Renderer $R, $mode)
    {
        $len = count($values);
        for ($i = 0; $i < $len; $i++) {
            $this->renderValue($values[$i], $R, $mode);
            if ($i < $len - 1) {
                $R->cdata(', ');
            }
        }
        return true;
    }

    /**
     * Render a link in a struct cloud. This should be good for most types, but can be overwritten if necessary.
     *
     * @param string|int $value the value stored in the database
     * @param \Doku_Renderer $R the renderer currently used to render the data
     * @param string $mode The mode the output is rendered in (eg. XHTML)
     * @param string $page the target to which should be linked
     * @param string $filter the filter to apply to the aggregations on $page
     * @param int $weight the scaled weight of the item. implemented as css font-size on the outside container
     * @param int|null $showCount count for the tag, only passed if summarize was set in config
     */
    public function renderTagCloudLink($value, \Doku_Renderer $R, $mode, $page, $filter, $weight, $showCount = null)
    {
        $value = $this->displayValue($value);
        if ($showCount) {
             $value .= " ($showCount)";
        }
        $R->internallink("$page?$filter", $value);
    }

    /**
     * This function is used to modify an aggregation query to add a filter
     * for the given column matching the given value. A type should add at
     * least a filter here but could do additional things like joining more
     * tables needed to handle more complex filters
     *
     * Important: $value might be an array. If so, the filter should check against
     * all provided values ORed together
     *
     * @param QueryBuilderWhere $add The where clause where statements can be added
     * @param string $tablealias The table the currently saved value(s) are stored in
     * @param string $colname The column name on above table to use in the SQL
     * @param string $comp The SQL comparator (LIKE, NOT LIKE, =, !=, etc)
     * @param string|string[] $value this is the user supplied value to compare against. might be multiple
     * @param string $op the logical operator this filter should use (AND|OR)
     */
    public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op)
    {
        /** @var QueryBuilderWhere $add Where additionional queries are added to */
        if (is_array($value)) {
            $add = $add->where($op); // sub where group
            $op = 'OR';
        }
        foreach ((array)$value as $item) {
            $pl = $add->getQB()->addValue($item);
            $add->where($op, "$tablealias.$colname $comp $pl");
        }
    }

    /**
     * Add the proper selection for this type to the current Query
     *
     * The default implementation here should be good for nearly all types, it simply
     * passes the given parameters to the query builder. But type may do more fancy
     * stuff here, eg. join more tables or select multiple values and combine them to
     * JSON. If you do, be sure implement a fitting rawValue() method.
     *
     * The passed $tablealias.$columnname might be a data_* table (referencing a single
     * row) or a multi_* table (referencing multiple rows). In the latter case the
     * multi table has already been joined with the proper conditions.
     *
     * You may assume a column alias named 'PID' to be available, should you need the
     * current page context for a join or sub select.
     *
     * @param QueryBuilder $QB
     * @param string $tablealias The table the currently saved value(s) are stored in
     * @param string $colname The column name on above table
     * @param string $alias The added selection *has* to use this column alias
     */
    public function select(QueryBuilder $QB, $tablealias, $colname, $alias)
    {
        $QB->addSelectColumn($tablealias, $colname, $alias);
    }

    /**
     * Sort results by this type
     *
     * The default implementation should be good for nearly all types. However some
     * types may need to do proper SQLite type casting to have the right order.
     *
     * Generally if you implemented @param QueryBuilder $QB
     * @param string $tablealias The table the currently saved value is stored in
     * @param string $colname The column name on above table (always single column!)
     * @param string $order either ASC or DESC
     * @see select() you probably want to implement this,
     * too.
     *
     */
    public function sort(QueryBuilder $QB, $tablealias, $colname, $order)
    {
        $QB->addOrderBy("$tablealias.$colname COLLATE NOCASE $order");
    }

    /**
     * Get the string by which to sort values of this type
     *
     * This implementation is designed to work both as registered function in sqlite
     * and to provide a string to be used in sorting values of this type in PHP.
     *
     * @param string|Value $value The string by which the types would usually be sorted
     *
     * @return string
     */
    public function getSortString($value)
    {
        if (is_string($value)) {
            return $value;
        }
        $display = $value->getDisplayValue();
        if (is_array($display)) {
            return blank($display[0]) ? "" : $display[0];
        }
        return $display;
    }

    /**
     * This allows types to apply a transformation to the value read by select()
     *
     * The returned value should always be a single, non-complex string. In general
     * it is the identifier a type stores in the database.
     *
     * This value will be used wherever the raw saved data is needed for comparisons.
     * The default implementations of renderValue() and valueEditor() will call this
     * function as well.
     *
     * @param string $value The value as returned by select()
     * @return string The value as saved in the database
     */
    public function rawValue($value)
    {
        return $value;
    }

    /**
     * This is called when a single string is needed to represent this Type's current
     * value as a single (non-HTML) string. Eg. in a dropdown or in autocompletion.
     *
     * @param string $value
     * @return string
     */
    public function displayValue($value)
    {
        return $this->rawValue($value);
    }

    /**
     * This is the value to be used as argument to a filter for another column.
     *
     * In a sense this is the counterpart to the @param string $value
     *
     * @return string
     * @see filter() function
     *
     */
    public function compareValue($value)
    {
        return $this->rawValue($value);
    }

    /**
     * Validate and optionally clean a single value
     *
     * This function needs to throw a validation exception when validation fails.
     * The exception message will be prefixed by the appropriate field on output
     *
     * The function should return the value as it should be saved later on.
     *
     * @param string|int $rawvalue
     * @return int|string the cleaned value
     * @throws ValidationException
     */
    public function validate($rawvalue)
    {
        return trim($rawvalue);
    }

    /**
     * Overwrite to handle Ajax requests
     *
     * A call to DOKU_BASE/lib/exe/ajax.php?call=plugin_struct&column=schema.name will
     * be redirected to this function on a fully initialized type. The result is
     * JSON encoded and returned to the caller. Access additional parameter via $INPUT
     * as usual
     *
     * @return mixed
     * @throws StructException when something goes wrong
     */
    public function handleAjax()
    {
        throw new StructException('not implemented');
    }

    /**
     * Convenience method to access plugin language strings
     *
     * @param string $string
     * @return string
     */
    public function getLang($string)
    {
        if (is_null($this->hlp)) $this->hlp = plugin_load('helper', 'struct');
        return $this->hlp->getLang($string);
    }

    /**
     * With what comparator should dynamic filters filter this type?
     *
     * This default does a LIKE operation
     *
     * @return string
     * @see Search::$COMPARATORS
     */
    public function getDefaultComparator()
    {
        return '*~';
    }
}
