<?php
/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @since         3.0.0
 * @license       https://opensource.org/licenses/mit-license.php MIT License
 */
namespace Cake\View\Widget;

use Cake\View\Form\ContextInterface;
use Cake\View\StringTemplate;
use DateTime;
use Exception;
use RuntimeException;

/**
 * Input widget class for generating a date time input widget.
 *
 * This class is intended as an internal implementation detail
 * of Cake\View\Helper\FormHelper and is not intended for direct use.
 */
class DateTimeWidget implements WidgetInterface
{
    /**
     * Select box widget.
     *
     * @var \Cake\View\Widget\SelectBoxWidget
     */
    protected $_select;

    /**
     * List of inputs that can be rendered
     *
     * @var string[]
     */
    protected $_selects = [
        'year',
        'month',
        'day',
        'hour',
        'minute',
        'second',
        'meridian',
    ];

    /**
     * Template instance.
     *
     * @var \Cake\View\StringTemplate
     */
    protected $_templates;

    /**
     * Constructor
     *
     * @param \Cake\View\StringTemplate $templates Templates list.
     * @param \Cake\View\Widget\SelectBoxWidget $selectBox Selectbox widget instance.
     */
    public function __construct(StringTemplate $templates, SelectBoxWidget $selectBox)
    {
        $this->_select = $selectBox;
        $this->_templates = $templates;
    }

    /**
     * Renders a date time widget
     *
     * - `name` - Set the input name.
     * - `disabled` - Either true or an array of options to disable.
     * - `val` - A date time string, integer or DateTime object
     * - `empty` - Set to true to add an empty option at the top of the
     *   option elements. Set to a string to define the display value of the
     *   empty option.
     *
     * In addition to the above options, the following options allow you to control
     * which input elements are generated. By setting any option to false you can disable
     * that input picker. In addition each picker allows you to set additional options
     * that are set as HTML properties on the picker.
     *
     * - `year` - Array of options for the year select box.
     * - `month` - Array of options for the month select box.
     * - `day` - Array of options for the day select box.
     * - `hour` - Array of options for the hour select box.
     * - `minute` - Array of options for the minute select box.
     * - `second` - Set to true to enable the seconds input. Defaults to false.
     * - `meridian` - Set to true to enable the meridian input. Defaults to false.
     *   The meridian will be enabled automatically if you choose a 12 hour format.
     *
     * The `year` option accepts the `start` and `end` options. These let you control
     * the year range that is generated. It defaults to +-5 years from today.
     *
     * The `month` option accepts the `name` option which allows you to get month
     * names instead of month numbers.
     *
     * The `hour` option allows you to set the following options:
     *
     * - `format` option which accepts 12 or 24, allowing
     *   you to indicate which hour format you want.
     * - `start` The hour to start the options at.
     * - `end` The hour to stop the options at.
     *
     * The start and end options are dependent on the format used. If the
     * value is out of the start/end range it will not be included.
     *
     * The `minute` option allows you to define the following options:
     *
     * - `interval` The interval to round options to.
     * - `round` Accepts `up` or `down`. Defines which direction the current value
     *   should be rounded to match the select options.
     *
     * @param array $data Data to render with.
     * @param \Cake\View\Form\ContextInterface $context The current form context.
     * @return string A generated select box.
     * @throws \RuntimeException When option data is invalid.
     */
    public function render(array $data, ContextInterface $context)
    {
        $data = $this->_normalizeData($data);

        $selected = $this->_deconstructDate($data['val'], $data);

        $templateOptions = ['templateVars' => $data['templateVars']];
        foreach ($this->_selects as $select) {
            if ($data[$select] === false || $data[$select] === null) {
                $templateOptions[$select] = '';
                unset($data[$select]);
                continue;
            }
            if (!is_array($data[$select])) {
                throw new RuntimeException(sprintf(
                    'Options for "%s" must be an array|false|null',
                    $select
                ));
            }
            $method = "_{$select}Select";
            $data[$select]['name'] = $data['name'] . '[' . $select . ']';
            $data[$select]['val'] = $selected[$select];

            if (!isset($data[$select]['empty'])) {
                $data[$select]['empty'] = $data['empty'];
            }
            if (!isset($data[$select]['disabled'])) {
                $data[$select]['disabled'] = $data['disabled'];
            }
            if (isset($data[$select]['templateVars']) && $templateOptions['templateVars']) {
                $data[$select]['templateVars'] = array_merge(
                    $templateOptions['templateVars'],
                    $data[$select]['templateVars']
                );
            }
            if (!isset($data[$select]['templateVars'])) {
                $data[$select]['templateVars'] = $templateOptions['templateVars'];
            }
            $templateOptions[$select] = $this->{$method}($data[$select], $context);
            unset($data[$select]);
        }
        unset($data['name'], $data['empty'], $data['disabled'], $data['val']);
        $templateOptions['attrs'] = $this->_templates->formatAttributes($data);

        return $this->_templates->format('dateWidget', $templateOptions);
    }

    /**
     * Normalize data.
     *
     * @param array $data Data to normalize.
     * @return array Normalized data.
     */
    protected function _normalizeData($data)
    {
        $data += [
            'name' => '',
            'empty' => false,
            'disabled' => null,
            'val' => null,
            'year' => [],
            'month' => [],
            'day' => [],
            'hour' => [],
            'minute' => [],
            'second' => [],
            'meridian' => null,
            'templateVars' => [],
        ];

        $timeFormat = isset($data['hour']['format']) ? $data['hour']['format'] : null;
        if ($timeFormat === 12 && !isset($data['meridian'])) {
            $data['meridian'] = [];
        }
        if ($timeFormat === 24) {
            $data['meridian'] = false;
        }

        return $data;
    }

    /**
     * Deconstructs the passed date value into all time units
     *
     * @param string|int|array|\DateTime|null $value Value to deconstruct.
     * @param array $options Options for conversion.
     * @return array
     */
    protected function _deconstructDate($value, $options)
    {
        if ($value === '' || $value === null) {
            return [
                'year' => '', 'month' => '', 'day' => '',
                'hour' => '', 'minute' => '', 'second' => '',
                'meridian' => '',
            ];
        }
        try {
            if (is_string($value) && !is_numeric($value)) {
                $date = new DateTime($value);
            } elseif (is_bool($value)) {
                $date = new DateTime();
            } elseif (is_int($value) || is_numeric($value)) {
                $date = new DateTime('@' . $value);
            } elseif (is_array($value)) {
                $dateArray = [
                    'year' => '', 'month' => '', 'day' => '',
                    'hour' => '', 'minute' => '', 'second' => '',
                    'meridian' => '',
                ];
                $validDate = false;
                foreach ($dateArray as $key => $dateValue) {
                    $exists = isset($value[$key]);
                    if ($exists) {
                        $validDate = true;
                    }
                    if ($exists && $value[$key] !== '') {
                        $dateArray[$key] = str_pad($value[$key], 2, '0', STR_PAD_LEFT);
                    }
                }
                if ($validDate) {
                    if (!isset($dateArray['second'])) {
                        $dateArray['second'] = 0;
                    }
                    if (!empty($value['meridian'])) {
                        $isAm = strtolower($dateArray['meridian']) === 'am';
                        $dateArray['hour'] = $isAm ? $dateArray['hour'] : $dateArray['hour'] + 12;
                    }
                    if (!empty($dateArray['minute']) && isset($options['minute']['interval'])) {
                        $dateArray['minute'] += $this->_adjustValue($dateArray['minute'], $options['minute']);
                        $dateArray['minute'] = str_pad((string)$dateArray['minute'], 2, '0', STR_PAD_LEFT);
                    }

                    return $dateArray;
                }

                $date = new DateTime();
            } else {
                /** @var \DateTime $value */
                $date = clone $value;
            }
        } catch (Exception $e) {
            $date = new DateTime();
        }

        if (isset($options['minute']['interval'])) {
            $change = $this->_adjustValue((int)$date->format('i'), $options['minute']);
            $date->modify($change > 0 ? "+$change minutes" : "$change minutes");
        }

        return [
            'year' => $date->format('Y'),
            'month' => $date->format('m'),
            'day' => $date->format('d'),
            'hour' => $date->format('H'),
            'minute' => $date->format('i'),
            'second' => $date->format('s'),
            'meridian' => $date->format('a'),
        ];
    }

    /**
     * Adjust $value based on rounding settings.
     *
     * @param int $value The value to adjust.
     * @param array $options The options containing interval and possibly round.
     * @return int The amount to adjust $value by.
     */
    protected function _adjustValue($value, $options)
    {
        $options += ['interval' => 1, 'round' => null];
        $changeValue = $value * (1 / $options['interval']);
        switch ($options['round']) {
            case 'up':
                $changeValue = ceil($changeValue);
                break;
            case 'down':
                $changeValue = floor($changeValue);
                break;
            default:
                $changeValue = round($changeValue);
        }

        return ($changeValue * $options['interval']) - $value;
    }

    /**
     * Generates a year select
     *
     * @param array $options Options list.
     * @param \Cake\View\Form\ContextInterface $context The current form context.
     * @return string
     */
    protected function _yearSelect($options, $context)
    {
        $options += [
            'name' => '',
            'val' => null,
            'start' => date('Y', strtotime('-5 years')),
            'end' => date('Y', strtotime('+5 years')),
            'order' => 'desc',
            'templateVars' => [],
            'options' => [],
        ];

        if (!empty($options['val'])) {
            $options['start'] = min($options['val'], $options['start']);
            $options['end'] = max($options['val'], $options['end']);
        }
        if (empty($options['options'])) {
            $options['options'] = $this->_generateNumbers($options['start'], $options['end']);
        }
        if ($options['order'] === 'desc') {
            $options['options'] = array_reverse($options['options'], true);
        }
        unset($options['start'], $options['end'], $options['order']);

        return $this->_select->render($options, $context);
    }

    /**
     * Generates a month select
     *
     * @param array $options The options to build the month select with
     * @param \Cake\View\Form\ContextInterface $context The current form context.
     * @return string
     */
    protected function _monthSelect($options, $context)
    {
        $options += [
            'name' => '',
            'names' => false,
            'val' => null,
            'leadingZeroKey' => true,
            'leadingZeroValue' => false,
            'templateVars' => [],
        ];

        if (empty($options['options'])) {
            if ($options['names'] === true) {
                $options['options'] = $this->_getMonthNames($options['leadingZeroKey']);
            } elseif (is_array($options['names'])) {
                $options['options'] = $options['names'];
            } else {
                $options['options'] = $this->_generateNumbers(1, 12, $options);
            }
        }

        unset($options['leadingZeroKey'], $options['leadingZeroValue'], $options['names']);

        return $this->_select->render($options, $context);
    }

    /**
     * Generates a day select
     *
     * @param array $options The options to generate a day select with.
     * @param \Cake\View\Form\ContextInterface $context The current form context.
     * @return string
     */
    protected function _daySelect($options, $context)
    {
        $options += [
            'name' => '',
            'val' => null,
            'leadingZeroKey' => true,
            'leadingZeroValue' => false,
            'templateVars' => [],
        ];
        $options['options'] = $this->_generateNumbers(1, 31, $options);

        unset($options['names'], $options['leadingZeroKey'], $options['leadingZeroValue']);

        return $this->_select->render($options, $context);
    }

    /**
     * Generates a hour select
     *
     * @param array $options The options to generate an hour select with
     * @param \Cake\View\Form\ContextInterface $context The current form context.
     * @return string
     */
    protected function _hourSelect($options, $context)
    {
        $options += [
            'name' => '',
            'val' => null,
            'format' => 24,
            'start' => null,
            'end' => null,
            'leadingZeroKey' => true,
            'leadingZeroValue' => false,
            'templateVars' => [],
        ];
        $is24 = $options['format'] == 24;

        $defaultStart = $is24 ? 0 : 1;
        $defaultEnd = $is24 ? 23 : 12;
        $options['start'] = max($defaultStart, $options['start']);

        $options['end'] = min($defaultEnd, $options['end']);
        if ($options['end'] === null) {
            $options['end'] = $defaultEnd;
        }

        if (!$is24 && $options['val'] > 12) {
            $options['val'] = sprintf('%02d', $options['val'] - 12);
        }
        if (!$is24 && in_array($options['val'], ['00', '0', 0], true)) {
            $options['val'] = 12;
        }

        if (empty($options['options'])) {
            $options['options'] = $this->_generateNumbers(
                $options['start'],
                $options['end'],
                $options
            );
        }

        unset(
            $options['end'],
            $options['start'],
            $options['format'],
            $options['leadingZeroKey'],
            $options['leadingZeroValue']
        );

        return $this->_select->render($options, $context);
    }

    /**
     * Generates a minute select
     *
     * @param array $options The options to generate a minute select with.
     * @param \Cake\View\Form\ContextInterface $context The current form context.
     * @return string
     */
    protected function _minuteSelect($options, $context)
    {
        $options += [
            'name' => '',
            'val' => null,
            'interval' => 1,
            'round' => 'up',
            'leadingZeroKey' => true,
            'leadingZeroValue' => true,
            'templateVars' => [],
        ];
        $options['interval'] = max($options['interval'], 1);
        if (empty($options['options'])) {
            $options['options'] = $this->_generateNumbers(0, 59, $options);
        }

        unset(
            $options['leadingZeroKey'],
            $options['leadingZeroValue'],
            $options['interval'],
            $options['round']
        );

        return $this->_select->render($options, $context);
    }

    /**
     * Generates a second select
     *
     * @param array $options The options to generate a second select with
     * @param \Cake\View\Form\ContextInterface $context The current form context.
     * @return string
     */
    protected function _secondSelect($options, $context)
    {
        $options += [
            'name' => '',
            'val' => null,
            'leadingZeroKey' => true,
            'leadingZeroValue' => true,
            'options' => $this->_generateNumbers(0, 59),
            'templateVars' => [],
        ];

        unset($options['leadingZeroKey'], $options['leadingZeroValue']);

        return $this->_select->render($options, $context);
    }

    /**
     * Generates a meridian select
     *
     * @param array $options The options to generate a meridian select with.
     * @param \Cake\View\Form\ContextInterface $context The current form context.
     * @return string
     */
    protected function _meridianSelect($options, $context)
    {
        $options += [
            'name' => '',
            'val' => null,
            'options' => ['am' => 'am', 'pm' => 'pm'],
            'templateVars' => [],
        ];

        return $this->_select->render($options, $context);
    }

    /**
     * Returns a translated list of month names
     *
     * @param bool $leadingZero Whether to generate month keys with leading zero.
     * @return array
     */
    protected function _getMonthNames($leadingZero = false)
    {
        $months = [
            '01' => __d('cake', 'January'),
            '02' => __d('cake', 'February'),
            '03' => __d('cake', 'March'),
            '04' => __d('cake', 'April'),
            '05' => __d('cake', 'May'),
            '06' => __d('cake', 'June'),
            '07' => __d('cake', 'July'),
            '08' => __d('cake', 'August'),
            '09' => __d('cake', 'September'),
            '10' => __d('cake', 'October'),
            '11' => __d('cake', 'November'),
            '12' => __d('cake', 'December'),
        ];

        if ($leadingZero === false) {
            $i = 1;
            foreach ($months as $key => $name) {
                unset($months[$key]);
                $months[$i++] = $name;
            }
        }

        return $months;
    }

    /**
     * Generates a range of numbers
     *
     * ### Options
     *
     * - leadingZeroKey - Set to true to add a leading 0 to single digit keys.
     * - leadingZeroValue - Set to true to add a leading 0 to single digit values.
     * - interval - The interval to generate numbers for. Defaults to 1.
     *
     * @param int $start Start of the range of numbers to generate
     * @param int $end End of the range of numbers to generate
     * @param array $options Options list.
     * @return array
     */
    protected function _generateNumbers($start, $end, $options = [])
    {
        $options += [
            'leadingZeroKey' => true,
            'leadingZeroValue' => true,
            'interval' => 1,
        ];

        $numbers = [];
        $i = $start;
        while ($i <= $end) {
            $key = (string)$i;
            $value = (string)$i;
            if ($options['leadingZeroKey'] === true) {
                $key = sprintf('%02d', $key);
            }
            if ($options['leadingZeroValue'] === true) {
                $value = sprintf('%02d', $value);
            }
            $numbers[$key] = $value;
            $i += $options['interval'];
        }

        return $numbers;
    }

    /**
     * Returns a list of fields that need to be secured for this widget.
     *
     * When the hour picker is in 24hr mode (null or format=24) the meridian
     * picker will be omitted.
     *
     * @param array $data The data to render.
     * @return array Array of fields to secure.
     */
    public function secureFields(array $data)
    {
        $data = $this->_normalizeData($data);

        $fields = [];
        foreach ($this->_selects as $select) {
            if ($data[$select] === false || $data[$select] === null) {
                continue;
            }

            $fields[] = $data['name'] . '[' . $select . ']';
        }

        return $fields;
    }
}
