<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    stats
 */

/**
 * Find hook-based page links under the admin_stats modules.
 *
 * @return array Page-links (each is a tuple)
 */
function find_hook_stats_page_links() : array
{
    static $ret = null;

    if ($ret === null) {
        require_lang('stats');

        $ret = [];

        $hooks = find_all_hook_obs('modules', 'admin_stats_redirects', 'Hook_admin_stats_redirects_');
        foreach ($hooks as $ob) {
            $info = $ob->info();
            if ($info !== null) {
                foreach ($info as $graph_name => $details) {
                    $ret[get_module_zone('admin_stats') . ':admin_stats:graph:' . $graph_name] = [do_lang_tempcode($details['label_lang_string']), $details['icon']];
                }
            }
        }

        $categories = stats_find_graph_categories();
        foreach ($categories as $category_name => $category) {
            $ret[get_module_zone('admin_stats') . ':admin_stats:category:' . $category_name] = [do_lang_tempcode($category['label_lang_string']), $category['icon']];
        }

        sort_maps_by($ret, 0);
    }

    return $ret;
}

/**
 * Script to track clicks to sites.
 *
 * @ignore
 */
function tracked_redirect_script()
{
    $url = get_param_string('url', null, INPUT_FILTER_URL_GENERAL);

    if (!is_our_server(cms_parse_url_safe($url, PHP_URL_HOST))) {
        $hash = get_param_string('hash', false, INPUT_FILTER_GET_COMPLEX);
        require_code('crypt');
        if (!ratchet_hash_verify($url, get_site_salt(), $hash)) {
            fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('eaea003159b8553baad2f829d41b9813')));
        }
    }

    $GLOBALS['SITE_DB']->query_insert('stats_link_tracker', [
        'c_date_and_time' => time(),
        'c_member_id' => get_member(),
        'c_ip_address' => get_ip_address(),
        'c_url' => cms_mb_substr($url, 0, 255),
    ]);

    if (addon_installed('stats')) {
        $event = get_param_string('event', null);
        if ($event !== null) {
            log_stats_event($event);
        }
    }

    require_code('site2');
    redirect_exit($url);
}

/**
 * Gather details on all active KPIs.
 *
 * @param  ?ID_TEXT $pivot_filter Filter only to this pivot (null: no filter)
 * @return array List of details tuples
 */
function gather_kpis(?string $pivot_filter = null) : array
{
    $where = [];
    if ($pivot_filter !== null) {
        $where['k_pivot'] = $pivot_filter;
    }
    $kpi_rows = $GLOBALS['SITE_DB']->query_select('stats_kpis', ['*'], $where, 'ORDER BY k_title');

    $kpis = [];
    foreach ($kpi_rows as $kpi_row) {
        $graph_name = $kpi_row['k_graph_name'];
        list($hook_ob, $graph_details) = stats_find_graph_details($graph_name, true/*Filters will be generated in KPI mode*/);
        if ($hook_ob === null) {
            continue;
        }

        $edit_url = build_url(['page' => 'admin_stats', 'type' => '_edit', 'id' => $kpi_row['id']], get_module_zone('admin_stats'));

        $kpi_method = $graph_details['support_kpis'];

        $filters = json_decode($kpi_row['k_filters'], true);
        if (!is_array($filters)) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('933d0b01b58d5a5ea70a31ecb0728172')));
        }

        $pivot = ($kpi_row['k_pivot'] == '') ? null : $kpi_row['k_pivot'];

        $graph_final_details = stats_generate_data($graph_name, $filters, $pivot, $hook_ob, $graph_details, true);

        $target = $kpi_row['k_target'];
        $current = array_pop($graph_final_details['data']);
        if ($current === null) {
            $hits_target = null;
        } else {
            if ($kpi_method == $hook_ob::KPI_HIGH_IS_GOOD) {
                $hits_target = ($current >= $target);
            } else {
                $hits_target = ($current <= $target);
            }
        }

        $kpis[] = [
            $kpi_row,
            $hook_ob,
            $graph_details,
            $graph_final_details,
            $filters,
            $pivot,
            $edit_url,
            $kpi_method,
            $target,
            $current,
            $hits_target,
        ];
    }

    return $kpis;
}

/**
 * Generate stats data.
 *
 * @param  string $graph_name Graph name
 * @param  array $filters Filter settings to take precedence
 * @param  ?mixed $pivot Pivot value to take precedence (null: none)
 * @param  ?object $hook_ob Hook object (null: look it up using $graph_name)
 * @param  ?array $graph_details Graph details (null: look it up using $graph_name)
 * @param  boolean $for_kpi Whether this is for setting up a KPI
 * @return ?array Graph data in standard map format (null: unknown)
 */
function stats_generate_data(string $graph_name, array $filters = [], $pivot = null, ?object &$hook_ob = null, ?array &$graph_details = null, bool $for_kpi = false) : ?array
{
    if (($hook_ob === null) || ($graph_details === null)) {
        list($hook_ob, $graph_details) = stats_find_graph_details($graph_name, $for_kpi);
    }

    if ($hook_ob instanceof CMSStatsProvider) {
        require_code('temporal2');

        list($filters, $pivot) = _stats_get_graph_context($graph_details, $filters, $pivot, false/*Can only generate data with dates converted to absolute first*/);
        return $hook_ob->generate_final_data($graph_name, ($pivot === null) ? '' : $pivot, $filters);
    }

    return null;
}

/**
 * Generate a stats graph.
 *
 * @param  string $graph_name Graph name
 * @param  array $filters Filter settings to take precedence
 * @param  ?mixed $pivot Pivot value to take precedence (null: none)
 * @param  ?object $hook_ob Hook object (null: look it up using $graph_name)
 * @param  ?array $graph_details Graph details (null: look it up using $graph_name)
 * @param  ?array $graph_final_details Graph data (null: look it up using $graph_name etc)
 * @param  boolean $for_kpi Whether this is for setting up a KPI
 * @return Tempcode Graph
 */
function stats_generate_graph(string $graph_name, array $filters = [], $pivot = null, ?object &$hook_ob = null, ?array &$graph_details = null, ?array &$graph_final_details = null, bool $for_kpi = false) : object
{
    if (($hook_ob === null) || ($graph_details === null)) {
        list($hook_ob, $graph_details) = stats_find_graph_details($graph_name, $for_kpi);
        if ($hook_ob === null) {
            return do_template('RED_ALERT', ['_GUID' => 'ce20d9674823ecc54ddf3059d9b386a6', 'TEXT' => do_lang_tempcode('MISSING_RESOURCE')]);
        }
    }

    if ($hook_ob instanceof CMSStatsBlob) {
        list($filters, $pivot) = _stats_get_graph_context($graph_details, $filters, $pivot, false/*Can only generate data with dates converted to absolute first*/);
        return $hook_ob->generate($graph_name, $filters);
    }

    if ($graph_final_details === null) {
        $graph_final_details = stats_generate_data($graph_name, $filters, $pivot, $hook_ob, $graph_details, $for_kpi);
        if ($graph_final_details === null) {
            return new Tempcode();
        }
    }

    list($filters, $pivot) = _stats_get_graph_context($graph_details, $filters, $pivot, $for_kpi);

    require_code('graphs');

    $data = $graph_final_details['data'];

    if (empty($data)) {
        return new Tempcode();
    }

    $x_axis_label = $graph_final_details['x_axis_label'];
    $y_axis_label = $graph_final_details['y_axis_label'];

    if ($graph_final_details['type'] === null) {
        if (strpos($pivot, '_of_') !== false) {
            $graph_final_details['type'] = CMSStatsProvider::GRAPH_BAR_CHART;
        } else {
            $graph_final_details['type'] = CMSStatsProvider::GRAPH_LINE_CHART;
        }
    }
    switch ($graph_final_details['type']) {
        case CMSStatsProvider::GRAPH_LINE_CHART:
            $keys = array_keys($data);
            $values = array_values($data);
            $_values = [];
            $_values[] = [
                'label' => do_lang('RESULTS'),
                'datapoints' => $values,
            ];
            if (count(array_unique($values)) != 0) { // Average line
                $average = array_sum($values) / count($values);
                $_values[] = [
                    'label' => do_lang('__AVERAGE'),
                    'datapoints' => array_fill(0, count($keys), $average),
                ];
            }
            if (isset($graph_details['support_kpis'])) {
                $kpi_rows = $GLOBALS['SITE_DB']->query_select('stats_kpis', ['k_title', 'k_target'], ['k_graph_name' => $graph_name, 'k_pivot' => $pivot]);
                foreach ($kpi_rows as $kpi_row) {
                    $_values[] = [
                        'label' => do_lang('KPI') . ': ' . $kpi_row['k_title'],
                        'datapoints' => array_fill(0, count($keys), $kpi_row['k_target']),
                    ];
                }
            }
            $graph_rendered = graph_line_chart($_values, $keys, $x_axis_label, $y_axis_label, [], [], '100%', '500px');
            break;

        case CMSStatsProvider::GRAPH_PIE_CHART:
            $graph_rendered = graph_pie_chart($data, [], [], '100%', '500px');
            break;

        case CMSStatsProvider::GRAPH_BAR_CHART:
            if (!empty($graph_final_details['limit_bars'])) {
                if (!empty($graph_final_details['low_first'])) {
                    asort($data);
                } else {
                    arsort($data);
                }
                $num_bars = 10;
                $_data = @array_slice($data, 0, $num_bars, true);
                if (count($_data) < count($data)) {
                    $other = 0;
                    $_remaining_data = @array_slice($data, $num_bars);
                    foreach ($_remaining_data as $val) {
                        $other += $val;
                    }
                    if (($other != 0) && (empty($graph_final_details['skip_other_bar']))) {
                        $_data[do_lang('OTHER')] = $val;
                    }
                }
                $data = $_data;
            }
            $graph_rendered = graph_bar_chart($data, $x_axis_label, $y_axis_label, [], [], '100%', '500px');
            break;

        default:
            fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('f81db5ac18ea5772bd6101cdf98637dd')));
    }

    return $graph_rendered;
}

/**
 * Generate a stats graph filter form.
 *
 * @param  string $graph_name Graph name
 * @param  ?object $hook_ob Hook object (null: look it up using $graph_name)
 * @param  ?array $graph_details Graph details (null: look it up using $graph_name)
 * @param  array $filters Filter settings to take precedence
 * @param  ?mixed $pivot Pivot value to take precedence (null: none)
 * @param  boolean $for_kpi Whether this is for setting up a KPI
 * @return Tempcode Graph filter form
 */
function stats_generate_graph_form(string $graph_name, ?object &$hook_ob = null, ?array &$graph_details = null, array $filters = [], $pivot = null, bool $for_kpi = false) : object
{
    $_fields = stats_generate_graph_form_fields($graph_name, $hook_ob, $graph_details, $filters, $pivot, $for_kpi);
    if ($_fields === null) {
        return new Tempcode();
    }
    list($fields, $hidden) = $_fields;

    $graph_form = do_template('FORM', [
        '_GUID' => '7f2e3db1499412cf95a984f0c5986247',
        'SKIP_WEBSTANDARDS' => true,
        'FIELDS' => $fields,
        'GET' => true,
        'URL' => get_self_url(true) . '#graph_' . $graph_name,
        'SUBMIT_ICON' => 'buttons/filter',
        'SUBMIT_NAME' => do_lang_tempcode('FILTER'),
        'HIDDEN' => $hidden,
        'TEXT' => '',
    ]);

    return $graph_form;
}

/**
 * Generate a stats graph filter form.
 *
 * @param  string $graph_name Graph name
 * @param  ?object $hook_ob Hook object (null: look it up using $graph_name)
 * @param  ?array $graph_details Graph details (null: look it up using $graph_name)
 * @param  array $filters Filter settings to take precedence
 * @param  ?mixed $pivot Pivot value to take precedence (null: none)
 * @param  boolean $for_kpi Whether this is for setting up a KPI
 * @return ?array A pair: Graph filter fields, Hidden fields (null: could not generate anything)
 */
function stats_generate_graph_form_fields(string $graph_name, ?object &$hook_ob = null, ?array &$graph_details = null, array $filters = [], $pivot = null, bool $for_kpi = false) : ?array
{
    if (($hook_ob === null) || ($graph_details === null)) {
        list($hook_ob, $graph_details) = stats_find_graph_details($graph_name, $for_kpi);
    }

    if (!$hook_ob instanceof CMSStatsProvider) {
        return null;
    }

    if ((empty($graph_details['filters'])) && ($graph_details['pivot'] === null)) {
        return null;
    }

    foreach ($graph_details['filters'] as $filter_name => $filter) {
        if ($filter !== null) {
            if (array_key_exists($filter_name, $filters)) {
                $filter->set_default($filters[$filter_name]);
            }
        }
    }
    if ($pivot !== null) {
        $graph_details['pivot']->set_default($pivot);
    }

    require_code('form_templates');

    $hidden = new Tempcode();

    $graph_fields = new Tempcode();
    foreach ($graph_details['filters'] as $filter) {
        if ($filter !== null) {
            $graph_fields->attach($filter->ui_component($hidden));
        }
    }
    if ($graph_details['pivot'] !== null) {
        $graph_fields->attach($graph_details['pivot']->ui_component($hidden));
    }

    return [$graph_fields, $hidden];
}

/**
 * Generate a stats results table.
 *
 * @param  string $graph_name Graph name
 * @param  array $filters Filter settings to take precedence
 * @param  ?mixed $pivot Pivot value to take precedence (null: none)
 * @param  ?object $hook_ob Hook object (null: look it up using $graph_name)
 * @param  ?array $graph_details Graph details (null: look it up using $graph_name)
 * @param  ?array $graph_final_details Graph data (null: look it up using $graph_name etc)
 * @param  boolean $for_kpi Whether this is for setting up a KPI
 * @return Tempcode Graph
 */
function stats_generate_results_table(string $graph_name, array $filters = [], $pivot = null, ?object &$hook_ob = null, ?array &$graph_details = null, ?array &$graph_final_details = null, bool $for_kpi = false) : object
{
    if (($hook_ob === null) || ($graph_details === null)) {
        list($hook_ob, $graph_details) = stats_find_graph_details($graph_name, $for_kpi);
    }

    if ($graph_final_details === null) {
        $graph_final_details = stats_generate_data($graph_name, $filters, $pivot, $hook_ob, $graph_details, $for_kpi);
    }

    if ($graph_final_details === null) {
        return new Tempcode();
    }

    require_code('templates_results_table');

    $data = $graph_final_details['data'];

    $x_axis_label = $graph_final_details['x_axis_label'];
    $y_axis_label = $graph_final_details['y_axis_label'];

    $current_ordering = get_param_string($graph_name . '_sort', (!empty($graph_final_details['low_first'])) ? 'y ASC' : 'y DESC', INPUT_FILTER_GET_COMPLEX);
    list($sortable, $sort_order) = explode(' ', $current_ordering, 2);
    $sortables = [
        'x' => $x_axis_label,
        'y' => $y_axis_label,
    ];
    if (((cms_strtoupper_ascii($sort_order) != 'ASC') && (cms_strtoupper_ascii($sort_order) != 'DESC')) || (!array_key_exists($sortable, $sortables))) {
        log_hack_attack_and_exit('ORDERBY_HACK');
    }

    $columns = [];
    $columns[] = $x_axis_label;
    $columns[] = $y_axis_label;
    $header_row = results_header_row($columns);

    switch ($sortable) {
        case 'x':
            if ($sort_order == 'ASC') {
                ksort($data, SORT_NATURAL | SORT_FLAG_CASE);
            } else {
                krsort($data, SORT_NATURAL | SORT_FLAG_CASE);
            }
            break;
        case 'y':
            if ($sort_order == 'ASC') {
                asort($data, SORT_NATURAL | SORT_FLAG_CASE);
            } else {
                arsort($data, SORT_NATURAL | SORT_FLAG_CASE);
            }
            break;
    }

    $start = get_param_integer($graph_name . '_start', 0);
    $max = get_param_integer($graph_name . '_max', 10);

    $results_entries = new Tempcode();
    $i = 0;
    foreach ($data as $key => $val) {
        if (is_integer($key)) {
            $key = strval($key);
        }

        if (($i >= $start) && ($i <= $start + $max)) {
            $_val = is_integer($val) ? integer_format($val, 0) : float_format($val, 2, true);
            $values = [
                $key,
                $_val,
            ];
            $results_entries->attach(results_entry($values, true));
        }
        $i++;
    }

    if ($results_entries->is_empty()) {
        return new Tempcode();
    }

    $results_table = results_table(
        $graph_details['label'],
        $start,
        $graph_name . '_start',
        $max,
        $graph_name . '_max',
        count($data),
        $header_row,
        $results_entries,
        $sortables,
        $sortable,
        $sort_order
    );

    return $results_table;
}

/**
 * Generate a stats spreadsheet from graph data.
 *
 * @param  string $spreadsheet_graph_name Graph name
 * @param  ?string $filename Filename (null: auto-generate)
 * @param  array $filters Filter settings to take precedence
 * @param  ?mixed $pivot Pivot value to take precedence (null: none)
 * @param  ?object $hook_ob Hook object (null: look it up using $graph_name)
 * @param  ?array $graph_details Graph details (null: look it up using $graph_name)
 * @return object Writer object
 */
function stats_generate_spreadsheet(string $spreadsheet_graph_name, ?string &$filename = null, array $filters = [], $pivot = null, ?object $hook_ob = null, ?array $graph_details = null) : object
{
    if (($hook_ob === null) || ($graph_details === null)) {
        list($hook_ob, $graph_details) = stats_find_graph_details($spreadsheet_graph_name);
    }

    require_code('temporal2');

    list($filters, $pivot) = _stats_get_graph_context($graph_details, $filters, $pivot);
    $graph_final_details = $hook_ob->generate_final_data($spreadsheet_graph_name, ($pivot === null) ? '' : $pivot, $filters);
    if ($graph_final_details === null) {
        fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('d287c7484b025692b12e98c44e31b86f')));
    }

    $x_axis_label = $graph_final_details['x_axis_label']->evaluate();
    $y_axis_label = $graph_final_details['y_axis_label']->evaluate();

    $spreadsheet_rows = [];
    foreach ($graph_final_details['data'] as $key => $val) {
        $_val = is_integer($val) ? integer_format($val, 0) : float_format($val, 2, true);
        $spreadsheet_rows[] = [$x_axis_label => $key, $y_axis_label => $_val];
    }

    require_code('files_spreadsheets_write');
    $filename = $spreadsheet_graph_name . '.' . spreadsheet_write_default();
    $path = null;
    return make_spreadsheet($path, $spreadsheet_rows, $filename);
}

/**
 * Get context details (filter & pivot settings) for a particular graph.
 *
 * @param  array $graph_details Map of graph details
 * @param  array $filters Filter settings to take precedence
 * @param  ?mixed $pivot Pivot value to take precedence (null: none)
 * @param  boolean $for_kpi Whether this is for setting up a KPI
 * @return array A pair: filters, pivot
 */
function _stats_get_graph_context(array $graph_details, array $filters = [], $pivot = null, bool $for_kpi = false) : array
{
    if (($pivot === null) && ($graph_details['pivot'] !== null)) {
        $_filters = [];
        $pivot = $graph_details['pivot']->read_value($_filters, $for_kpi);
    }
    foreach ($graph_details['filters'] as $filter) {
        if ($filter !== null) {
            $filter->read_value($filters, $for_kpi);
        }
    }
    return [$filters, $pivot];
}

/**
 * Get a list of all stat categories.
 *
 * @return array The categories (each is a map)
 */
function stats_find_graph_categories() : array
{
    $categories = [];
    $hooks = find_all_hook_obs('modules', 'admin_stats', 'Hook_admin_stats_');
    foreach ($hooks as $ob) {
        $_categories = $ob->category_info();
        if ($_categories !== null) {
            $categories += $_categories;
        }
    }
    ksort($categories);
    return $categories;
}

/**
 * Find all the graphs in a stats category.
 *
 * @param  ?string $category_name The category name (null: all categories)
 * @return array Map between graph name and pair of graph object and graph details
 */
function stats_find_graphs_in_category(?string $category_name = null) : array
{
    $graphs = [];

    $hooks = find_all_hook_obs('modules', 'admin_stats', 'Hook_admin_stats_');
    foreach ($hooks as $ob) {
        $info = $ob->info();
        if ($info !== null) {
            foreach ($info as $graph_name => $graph_details) {
                if (($category_name === null) || ($graph_details['category'] === $category_name)) {
                    $graphs[$graph_name] = [$ob, $graph_details];
                }
            }
        }
    }

    return $graphs;
}

/**
 * Find all the graphs in a stats category.
 *
 * @param  string $graph_name The graph name
 * @param  boolean $for_kpi Whether this is for setting up a KPI
 * @return ?array A pair: graph object and graph details (null: could not find graph)
 */
function stats_find_graph_details(string $graph_name, bool $for_kpi = false) : ?array
{
    $graphs = [];

    $hooks = find_all_hook_obs('modules', 'admin_stats', 'Hook_admin_stats_');
    foreach ($hooks as $ob) {
        $info = $ob->info($for_kpi);
        if ($info !== null) {
            if (array_key_exists($graph_name, $info)) {
                return [$ob, $info[$graph_name]];
            }
        }
    }

    return null;
}

/**
 * Base stats hook class.
 *
 * @package stats
 */
abstract class CMSStatsHookBase
{
    /**
     * Find metadata about stats categories that are defined by this stats hook.
     *
     * @return ?array Map of metadata (null: hook is disabled)
     */
    public function category_info() : ?array
    {
        return [];
    }

    /**
     * Find metadata about stats graphs that are provided by this stats hook.
     *
     * @param  boolean $for_kpi Whether this is for setting up a KPI
     * @return ?array Map of metadata (null: hook is disabled)
     */
    abstract public function info(bool $for_kpi = false) : ?array;
}

/**
 * Base stats hook class for stats providers.
 *
 * @package stats
 */
abstract class CMSStatsProvider extends CMSStatsHookBase
{
    public const GRAPH_LINE_CHART = 1;
    public const GRAPH_PIE_CHART = 2;
    public const GRAPH_BAR_CHART = 3;

    public const KPI_HIGH_IS_GOOD = 1;
    public const KPI_LOW_IS_GOOD = 2;

    /**
     * Find all the feedback type codes. Useful for creating filters that are filtering by feedback type code.
     *
     * @return array Codes
     */
    protected function find_all_feedback_type_codes() : array
    {
        require_code('content');

        $cma_hooks = find_all_hook_obs('systems', 'content_meta_aware', 'Hook_content_meta_aware_') + find_all_hook_obs('systems', 'resource_meta_aware', 'Hook_resource_meta_aware_');
        $feedback_type_codes = [];
        foreach ($cma_hooks as $content_type => $hook_ob) {
            $info = $hook_ob->info();
            if (($info !== null) && ($info['feedback_type_code'] !== null)) {
                $feedback_type_codes[$info['feedback_type_code']] = do_lang($info['content_type_label']);
            }
        }

        asort($feedback_type_codes, SORT_FLAG_CASE);

        return $feedback_type_codes;
    }

    /**
     * Find all the content types. Useful for creating filters that are filtering by content types.
     *
     * @return array Content types
     */
    protected function find_all_content_types() : array
    {
        require_code('content');

        $cma_hooks = find_all_hook_obs('systems', 'content_meta_aware', 'Hook_content_meta_aware_');
        $content_types = [];
        foreach ($cma_hooks as $content_type => $hook_ob) {
            $info = $hook_ob->info();
            if (($info !== null) && ($info['views_field'] !== null) && ($info['id_field'] !== null) && ($info['title_field'] !== null)) {
                $content_types[$content_type] = do_lang($info['content_type_label']);
            }
        }

        asort($content_types, SORT_FLAG_CASE);

        return $content_types;
    }

    /**
     * Find which bracket a value fits in.
     *
     * @param  array $brackets List of brackets
     * @param  integer $value Value
     * @return ?string Bracket (null: none)
     */
    protected function find_value_bracket(array $brackets, int $value) : ?string
    {
        $bracket = null;
        foreach ($brackets as $_bracket) {
            $matches = [];
            if (preg_match('#^(\d+)$#', $_bracket, $matches) != 0) {
                if ($value == intval($matches[1])) {
                    $bracket = $_bracket;
                    break;
                }
            } elseif (preg_match('#^<(\d+)$#', $_bracket, $matches) != 0) {
                if ($value < intval($matches[1])) {
                    $bracket = $_bracket;
                    break;
                }
            } elseif (preg_match('#^(\d+)-(\d+)$#', $_bracket, $matches) != 0) {
                if (($value >= intval($matches[1])) && ($value <= intval($matches[2]))) {
                    $bracket = $_bracket;
                    break;
                }
            } elseif (preg_match('#^(\d+)\+$#', $_bracket, $matches) != 0) {
                if ($value >= intval($matches[1])) {
                    $bracket = $_bracket;
                    break;
                }
            }
        }
        return $bracket;
    }

    /**
     * Get a list of all the standard date pivot types.
     *
     * @param  boolean $include_of_types Whether to include non-series pivots
     * @return array List of standardised date pivot names
     */
    protected function get_date_pivots(bool $include_of_types = true) : array
    {
        require_lang('dates');

        $ret = [];
        if ($include_of_types) {
            $ret['hour_of_day'] = do_lang_tempcode('PIVOT_X_OF', do_lang_tempcode('HOUR'));
        }
        $ret['day_series'] = do_lang_tempcode('PIVOT_X_SERIES', do_lang_tempcode('DAY'));
        if ($include_of_types) {
            $ret['day_of_week'] = do_lang_tempcode('PIVOT_X_OF', do_lang_tempcode('DAY'));
        }
        $ret['week_series'] = do_lang_tempcode('PIVOT_X_SERIES', do_lang_tempcode('_WEEK'));
        if ($include_of_types) {
            $ret['week_of_year'] = do_lang_tempcode('PIVOT_X_OF', do_lang_tempcode('_WEEK'));
        }
        $ret['month_series'] = do_lang_tempcode('PIVOT_X_SERIES', do_lang_tempcode('MONTH'));
        if ($include_of_types) {
            $ret['month_of_year'] = do_lang_tempcode('PIVOT_X_OF', do_lang_tempcode('MONTH'));
        }
        $ret['quarter_series'] = do_lang_tempcode('PIVOT_X_SERIES', do_lang_tempcode('CALENDAR_QUARTER'));
        if ($include_of_types) {
            $ret['quarter_of_year'] = do_lang_tempcode('PIVOT_X_OF', do_lang_tempcode('CALENDAR_QUARTER'));
        }
        $ret['year_series'] = do_lang_tempcode('PIVOT_X_SERIES', do_lang_tempcode('YEAR'));
        return $ret;
    }

    /**
     * Preprocess raw data in the database into something we can efficiently draw graphs/conclusions from.
     *
     * @param  TIME $start_time Start timestamp
     * @param  TIME $end_time End timestamp
     * @param  array $data_buckets Map of data buckets; a map of bucket name to nested maps with the following maps in sequence: 'month', 'pivot', 'value' (then further map data) ; extended and returned by reference
     */
    public function preprocess_raw_data(int $start_time, int $end_time, array &$data_buckets)
    {
    }

    /**
     * Preprocess raw data in the database into something we can efficiently draw graphs/conclusions from.
     * This is for flat and timeless data.
     *
     * @param  TIME $start_time Start timestamp
     * @param  TIME $end_time End timestamp
     * @param  array $data_buckets Map of data buckets; a map of bucket name to nested maps
     */
    public function preprocess_raw_data_flat(int $start_time, int $end_time, array &$data_buckets)
    {
    }

    /**
     * Generate final data from preprocessed data.
     *
     * @param  string $bucket Data bucket we want data for
     * @param  string $pivot Pivot value
     * @param  array $filters Map of filters (including pivot if applicable)
     * @return ?array Final data in standardised map format (null: could not generate)
     */
    abstract public function generate_final_data(string $bucket, string $pivot, array $filters) : ?array;


    /**
     * Get the pivot value (date-differentiation value within a bucket) for a standard date pivot type.
     *
     * @param  string $pivot Pivot type
     * @param  TIME $timestamp Timestamp
     * @return integer Pivot value
     */
    protected function calculate_date_pivot_value(string $pivot, int $timestamp) : int
    {
        require_code('temporal');

        switch ($pivot) {
            case 'hour_of_day':
                return intval(cms_date('H', $timestamp));
            case 'day_series':
                return to_epoch_interval_index($timestamp, 'days');
            case 'day_of_week':
                return intval(cms_date('w', $timestamp));
            case 'week_series':
                // Proper Monday week start handling (we have to use Monday regardless of ssw because PHP only allows that)
                $epoch = 345600;
                return to_epoch_interval_index($timestamp, 'weeks', $epoch);
            case 'week_of_year':
                return intval(cms_date('W', $timestamp));
            case 'month_series':
                return to_epoch_interval_index($timestamp, 'months');
            case 'month_of_year':
                return intval(cms_date('m', $timestamp));
            case 'quarter_series':
                $month = to_epoch_interval_index($timestamp, 'months');
                return intval(floor($month / 3.0));
            case 'quarter_of_year':
                $_month = to_epoch_interval_index($timestamp, 'months');
                $month = ($_month % 12);
                return intval($month / 3);
            case 'year_series':
                return to_epoch_interval_index($timestamp, 'years');
        }

        fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('3ad7fc19c15355a88480afc058c36e78')));
        return 0;
    }

    /**
     * Fill up an array with all standard date pivot values between a month range.
     *
     * @param  string $pivot Pivot type
     * @param  integer $start_month Start month (counting from 1970)
     * @param  integer $end_month End month (counting from 1970)
     * @return array All pivot values mapping to 0
     */
    protected function fill_data_by_date_pivots(string $pivot, int $start_month, int $end_month) : array
    {
        require_code('temporal');

        $pivot_value = mixed();

        $data = [];
        switch ($pivot) {
            case 'hour_of_day':
                for ($i = 0; $i <= 23; $i++) {
                    $pivot_value = $i;
                    $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                    $data[$pivot_value] = 0;
                }
                break;

            case 'day_series':
                for ($i = $start_month; $i <= $end_month; $i++) {
                    $timestamp = from_epoch_interval_index($i, 'months');
                    $start_days = to_epoch_interval_index($timestamp, 'days');

                    list($year, $month) = array_map('intval', explode('-', cms_date('Y-m', $timestamp)));
                    $days_in_month = cal_days_in_month(CAL_GREGORIAN, $month, $year);
                    for ($j = 1; $j <= $days_in_month; $j++) {
                        $pivot_value = $start_days + ($j - 1);
                        $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                        $data[$pivot_value] = 0;
                    }
                }
                break;

            case 'day_of_week':
                for ($i = 0; $i <= 6; $i++) {
                    $pivot_value = $i;
                    $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                    $data[$pivot_value] = 0;
                }
                break;

            case 'week_series':
                for ($i = $start_month; $i <= $end_month; $i++) {
                    $timestamp = from_epoch_interval_index($i, 'months');
                    list($year, $month) = array_map('intval', explode('-', cms_date('Y-m', $timestamp)));

                    // Gather the timestamp range for this month
                    $start_timestamp = cms_mktime(0, 0, 0, $month, 1, $year);
                    $end_timestamp = cms_mktime(0, 0, 0, $month + 1, 1, $year) - 1; // -1 so we end on 11:59:59pm the last day of the month instead of possibly accidentally counting an extra week

                    // Proper Monday week start handling (we have to use Monday regardless of ssw because PHP only allows that)
                    $epoch = 345600;

                    // Calculate the weeks in this month
                    $first_week = to_epoch_interval_index($start_timestamp, 'weeks', $epoch);
                    $last_week = to_epoch_interval_index($end_timestamp, 'weeks', $epoch);

                    for ($j = $first_week; $j <= $last_week; $j++) {
                        $pivot_value = $j;
                        $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                        $data[$pivot_value] = 0;
                    }
                }
                break;

            case 'week_of_year':
                for ($i = 1; $i <= 53; $i++) {
                    $pivot_value = $i;
                    $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                    $data[$pivot_value] = 0;
                }
                break;

            case 'month_series':
                for ($i = $start_month; $i <= $end_month; $i++) {
                    // No special handling needed because buckets are already handled by month
                    $pivot_value = $i;
                    $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                    $data[$pivot_value] = 0;
                }
                break;

            case 'month_of_year':
                for ($i = 1; $i <= 12; $i++) {
                    $pivot_value = $i;
                    $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                    $data[$pivot_value] = 0;
                }
                break;

            case 'quarter_series':
                for ($i = $start_month; $i <= $end_month; $i++) {
                    if (($i % 3 == 0) || (empty($data))) {
                        $quarter = intval($i / 3);
                        $pivot_value = $quarter;
                        $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                        $data[$pivot_value] = 0;
                    }
                }
                break;

            case 'quarter_of_year':
                for ($i = 0; $i <= 3; $i++) {
                    $pivot_value = $i;
                    $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                    $data[$pivot_value] = 0;
                }
                break;

            case 'year_series':
                for ($i = $start_month; $i <= $end_month; $i++) {
                    if (($i % 12 == 0) || (empty($data))) {
                        $timestamp = from_epoch_interval_index($i, 'months');
                        $year = to_epoch_interval_index($timestamp, 'years');
                        $pivot_value = $year;
                        $pivot_value = $this->make_date_pivot_value_nice($pivot, $pivot_value);
                        $data[$pivot_value] = 0;
                    }
                }
                break;

            default:
                fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('b7e50ce76c7f534d9b736f432fcb0072')));
        }
        return $data;
    }

    /**
     * Convert a month range filter array to a range pair if it only specifies one value.
     *
     * @param  mixed $_range_value The value of the $bucket . __month_range filter property
     * @return array An array pair of start, end indicating the month range; start and end are the number of months since epoch (1970)
     */
    protected function convert_month_range_filter_to_pair($_range_value) : array
    {
        // Work out what our range value is as an integer if applicable if $_range_value is not already a range pair
        $range_value = null;
        if (is_array($_range_value) && (count($_range_value) == 1) && (is_integer($_range_value[0]))) {
            $range_value = $_range_value[0];
        }
        if (is_integer($_range_value)) {
            $range_value = $_range_value;
        }

        // If $range_value has a value, this means $_range_value had only one value. Treat this as a month range length from (now - value) to now.
        if ($range_value !== null) {
            $epoch = new DateTime('1970-01-01');
            $_start = new DateTime(date('Y-m-d H:i:s', strtotime(strval(0 - ($range_value - 1)) . ' months')));
            $_end = new DateTime();

            $start_interval = $_start->diff($epoch);
            $start_months = ($start_interval->y * 12) + $start_interval->m;

            $end_interval = $_end->diff($epoch);
            $end_months = ($end_interval->y * 12) + $end_interval->m;

            return [$start_months, $end_months];
        }

        // If $_range_value is an array with exactly two integer values, assume it is already in month range format and return as-is.
        if (is_array($_range_value) && (count($_range_value) == 2) && is_integer($_range_value[0]) && is_integer($_range_value[1])) {
            return [$_range_value[0], $_range_value[1]];
        }

        // If we reach this point, then $_range_value was invalid.
        warn_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('e4d7c28853cb5b6da6d4a315774aae54')));
        return [];
    }

    /**
     * Make a date pivot value look nice.
     * TODO: make $pivot_value type integer when LEGACY code is removed.
     *
     * @param  string $pivot Current pivot
     * @param  mixed $pivot_value Pivot value
     * @return string Nice looking pivot value
     */
    protected function make_date_pivot_value_nice(string $pivot, $pivot_value) : string
    {
        require_lang('dates');

        switch ($pivot) {
            case 'hour_of_day':
                return trim(cms_date('ga', cms_mktime($pivot_value)));

            case 'day_series':
                // LEGACY
                if (is_string($pivot_value)) {
                    return $pivot_value;
                }

                $timestamp = from_epoch_interval_index($pivot_value, 'days');
                return cms_date('Y-m-d', $timestamp);

            case 'day_of_week':
                $dows = [
                    'SUNDAY',
                    'MONDAY',
                    'TUESDAY',
                    'WEDNESDAY',
                    'THURSDAY',
                    'FRIDAY',
                    'SATURDAY',
                ];
                return do_lang($dows[$pivot_value]);

            case 'week_series':
                // Proper Monday week start handling (we have to use Monday regardless of ssw because PHP only allows that)
                $epoch = 345600;
                $timestamp = from_epoch_interval_index($pivot_value, 'weeks', $epoch);
                $year = intval(cms_date('Y', $timestamp));
                $week = intval(cms_date('W', $timestamp));
                return strval($year) . '-W' . str_pad(strval($week), 2, '0', STR_PAD_LEFT);

            case 'week_of_year':
                return do_lang('WEEK', comcode_escape(strval($pivot_value)));

            case 'month_series':
                $timestamp = from_epoch_interval_index($pivot_value, 'months');
                return cms_date('Y-m', $timestamp);

            case 'month_of_year':
                $months = [
                    'JANUARY',
                    'FEBRUARY',
                    'MARCH',
                    'APRIL',
                    'MAY',
                    'JUNE',
                    'JULY',
                    'AUGUST',
                    'SEPTEMBER',
                    'OCTOBER',
                    'NOVEMBER',
                    'DECEMBER',
                ];
                return do_lang($months[$pivot_value - 1]);

            case 'quarter_series':
                $timestamp = from_epoch_interval_index($pivot_value * 3, 'months');
                $quarter = ($pivot_value % 4) + 1;
                return cms_date('Y', $timestamp) . ' Q' . strval($quarter);

            case 'quarter_of_year':
                return 'Q' . strval($pivot_value + 1);

            case 'year_series':
                // LEGACY
                if ($pivot_value >= 1970) {
                    return strval($pivot_value);
                }

                $timestamp = from_epoch_interval_index($pivot_value, 'years');
                return cms_date('Y', $timestamp);
        }

        return is_integer($pivot_value) ? strval($pivot_value) : $pivot_value;
    }
}

/**
 * Base stats hook class for stats blob providers. These are hooks that provide graphs, but cannot break down data within the API (for tables or spreadsheets etc).
 *
 * @package stats
 */
abstract class CMSStatsBlob extends CMSStatsHookBase
{
    /**
     * Generate graph.
     *
     * @param  string $graph_name Graph name
     * @param  array $filters Filter settings to take precedence
     * @return Tempcode Graph
     */
    abstract public function generate(string $graph_name, array $filters) : object;
}

/**
 * Base stats hook class for simple redirect hooks.
 *
 * @package stats
 */
abstract class CMSStatsRedirect extends CMSStatsHookBase
{
    /**
     * Get the hook's redirect URL.
     *
     * @param  string $bucket_name The bucket name
     * @return URLPATH Redirect URL
     */
    abstract public function get_redirect_url(string $bucket_name) : string;
}

/**
 * Get the month bounds of what dates we have stats data for.
 *
 * @return array A pair: min month, max month
 */
function find_known_stats_date_month_bounds() : array
{
    require_code('temporal');

    static $min_month = null, $max_month = null;
    if ($min_month === null) {
        $min_month = $GLOBALS['SITE_DB']->query_select_value('stats_preprocessed', 'MIN(p_month)');
        if ($min_month === null) {
            $min_month = to_epoch_interval_index(time(), 'months');
        }
    }
    if ($max_month === null) {
        $max_month = $GLOBALS['SITE_DB']->query_select_value('stats_preprocessed', 'MAX(p_month)');
        if ($max_month === null) {
            $max_month = to_epoch_interval_index(time(), 'months');
        }
    }
    return [$min_month, $max_month];
}

/**
 * Base class for stat filters/pivots.
 *
 * @package stats
 */
abstract class CMSStatsFilter
{
    public $filter_name;
    protected $label;
    protected $default;

    /**
     * Get the inputting UI for the filter.
     *
     * @param  Tempcode $hidden The hidden field
     * @return Tempcode The input field
     */
    abstract public function ui_component(object &$hidden) : object;


    /**
     * Read in and/or post-process filter value.
     *
     * @param  array $filters An array of our current filters; passed by reference and modified as they are read in
     * @param  ?boolean $for_kpi Whether we want this for a KPI (null: an object was initiated)
     * @return mixed The filter
     */
    public function read_value(array &$filters, ?bool $for_kpi = null)
    {
        if (isset($filters[$this->filter_name])) {
            $default = $filters[$this->filter_name];
        } else {
            $default = $this->default;
        }

        $filters[$this->filter_name] = either_param_string($this->filter_name, $default);
        return $filters[$this->filter_name];
    }

    /**
     * Set the default. Useful when defaults are saved, rather than in the GET environment.
     *
     * @param  mixed $default The new default
     * @param  ?boolean $for_kpi Whether this is a setting for a KPI (null: as object was initiated)
     */
    public function set_default($default, ?bool $for_kpi = null)
    {
        $this->default = $default;
    }
}

/**
 * Class for stat text input filters.
 *
 * @package stats
 */
class CMSStatsTextFilter extends CMSStatsFilter
{
    /**
     * Constructor.
     *
     * @param  string $filter_name Filter name
     * @param  Tempcode $label Label
     * @param  string $default Default
     */
    public function __construct(string $filter_name, object $label, string $default = '')
    {
        $this->filter_name = $filter_name;
        $this->label = $label;
        $this->default = $default;
    }

    /**
     * Get the inputting UI for the filter.
     *
     * @param  Tempcode $hidden The hidden field
     * @return Tempcode The input field
     */
    public function ui_component(object &$hidden) : object
    {
        require_code('form_templates');
        $filters = [];
        return form_input_line(do_lang_tempcode('_FILTER', $this->label/*Tempcode*/), new Tempcode(), $this->filter_name, $this->read_value($filters), $this->default != '');
    }
}

/**
 * Class for stat text input filters.
 *
 * @package stats
 */
class CMSStatsTickFilter extends CMSStatsFilter
{
    /**
     * Constructor.
     *
     * @param  string $filter_name Filter name
     * @param  Tempcode $label Label
     * @param  boolean $default Default
     */
    public function __construct(string $filter_name, object $label, bool $default = true)
    {
        $this->filter_name = $filter_name;
        $this->label = $label;
        $this->default = $default;
    }

    /**
     * Get the inputting UI for the filter.
     *
     * @param  Tempcode $hidden The hidden field
     * @return Tempcode The input field
     */
    public function ui_component(object &$hidden) : object
    {
        $hidden->attach(form_input_hidden($this->filter_name, '0'));

        require_code('form_templates');
        $filters = [];
        return form_input_tick(do_lang_tempcode('_FILTER', $this->label/*Tempcode*/), new Tempcode(), $this->filter_name, $this->read_value($filters));
    }

    /**
     * Read in and/or post-process filter value.
     *
     * @param  array $filters An array of our current filters; passed by reference and modified as they are read in
     * @param  ?boolean $for_kpi Whether we want this for a KPI (null: an object was initiated)
     * @return mixed The filter
     */
    public function read_value(array &$filters, ?bool $for_kpi = null)
    {
        if (isset($filters[$this->filter_name])) {
            $default = $filters[$this->filter_name];
            if ($default === true) {
                $default = '1';
            } elseif ($default === false) {
                $default = '0';
            }
        } else {
            $default = '1';
        }

        $filters[$this->filter_name] = either_param_integer($this->filter_name, intval($default)) == 1;
        return $filters[$this->filter_name];
    }
}

/**
 * Class for stat list input filters.
 *
 * @package stats
 */
class CMSStatsListFilter extends CMSStatsFilter
{
    protected $list;

    /**
     * Constructor.
     *
     * @param  string $filter_name Filter name
     * @param  Tempcode $label Label
     * @param  array $list List (a map)
     * @param  string $default Default
     */
    public function __construct(string $filter_name, object $label, array $list, string $default = '')
    {
        $this->filter_name = $filter_name;
        $this->label = $label;
        $this->list = $list;
        $this->default = $default;
    }

    /**
     * Get the inputting UI for the filter.
     *
     * @param  Tempcode $hidden The hidden field
     * @return Tempcode The input field
     */
    public function ui_component(object &$hidden) : object
    {
        require_code('form_templates');
        $list = new Tempcode();
        $filters = [];
        $list->attach(form_input_list_entry('', '' == $this->read_value($filters), ''));
        foreach ($this->list as $key => $val) {
            $list->attach(form_input_list_entry($key, $key == $this->read_value($filters), $val));
        }
        return form_input_list(do_lang_tempcode('_FILTER', $this->label), new Tempcode(), $this->filter_name, $list, null, false, false);
    }

    /**
     * Get the list values for this filter.
     *
     * @return array List values
     */
    public function get_list_values() : array
    {
        return $this->list;
    }
}

/**
 * Class for stat month date range input filters.
 *
 * @package stats
 */
class CMSStatsDateMonthRangeFilter extends CMSStatsFilter
{
    protected $for_kpi = false;

    /**
     * Constructor.
     *
     * @param  string $filter_name Filter name
     * @param  Tempcode $label Label
     * @param  ?mixed $default Default (null: past year)
     * @param  boolean $for_kpi Whether this is for setting up a KPI
     */
    public function __construct(string $filter_name, object $label, $default = null, bool $for_kpi = false)
    {
        if ($default === null) {
            if ($for_kpi) {
                $default = 12; // 12 months back
            } else {
                list($min_month, $max_month) = find_known_stats_date_month_bounds();
                $min_month = max($min_month, $max_month - 12);
                $default = [$min_month, $max_month];
            }
        }

        $this->filter_name = $filter_name;
        $this->label = $label;
        $this->default = $default;
        $this->for_kpi = $for_kpi;
    }

    /**
     * Get the inputting UI for the filter.
     *
     * @param  Tempcode $hidden The hidden field
     * @return Tempcode The input field
     */
    public function ui_component(object &$hidden) : object
    {
        require_code('form_templates');

        require_lang('dates');

        $filters = [];
        $value = $this->read_value($filters);

        if ($this->for_kpi) {
            $_options = [
                1 => do_lang_tempcode('MONTHS', escape_html(integer_format(1))),
                3 => do_lang_tempcode('MONTHS', escape_html(integer_format(3))),
                6 => do_lang_tempcode('MONTHS', escape_html(integer_format(6))),
                12 => do_lang_tempcode('YEARS', escape_html(integer_format(1))),
                24 => do_lang_tempcode('YEARS', escape_html(integer_format(2))),
                36 => do_lang_tempcode('YEARS', escape_html(integer_format(3))),
                60 => do_lang_tempcode('YEARS', escape_html(integer_format(5))),
            ];

            $options = new Tempcode();
            foreach ($_options as $option_key => $option_label) {
                if (!is_string($option_key)) {
                    $option_key = strval($option_key);
                }
                $options->attach(form_input_list_entry($option_key, $value == $option_key, $option_label));
            }

            return form_input_list($this->label, new Tempcode(), $this->filter_name, $options, null, false, true);
        }

        list($min_month, $max_month) = find_known_stats_date_month_bounds();

        $month_array = [
            'JANUARY',
            'FEBRUARY',
            'MARCH',
            'APRIL',
            'MAY',
            'JUNE',
            'JULY',
            'AUGUST',
            'SEPTEMBER',
            'OCTOBER',
            'NOVEMBER',
            'DECEMBER',
        ];

        $months = [];
        for ($month = min($this->default[0], $min_month); $month <= max($this->default[1], $max_month); $month++) {
            $year = 1970 + intval(floor(floatval($month) / 12.0));

            $val = protect_from_escaping(strval($year) . ', ' . do_lang($month_array[($month % 12)]));
            $months[$month] = $val;
        }

        $tabindex = get_form_field_tabindex();

        $input = do_template('FORM_SCREEN_INPUT_STATS_DATE_RANGE', [
            '_GUID' => '01c83bc8745a294da28f13e6ab4f47a1',
            'TABINDEX' => strval($tabindex),
            'NAME' => $this->filter_name,
            'MONTHS' => $months,
            'START' => strval($value[0]),
            'END' => strval($value[1]),
        ]);

        return _form_input($this->filter_name, $this->label, new Tempcode(), $input, true, false, $tabindex, false, true);
    }

    /**
     * Set the default. Useful when defaults are saved, rather than in the GET environment.
     *
     * @param  mixed $default The new default
     * @param  ?boolean $for_kpi Whether this is a setting for a KPI (null: as object was initiated)
     */
    public function set_default($default, ?bool $for_kpi = null)
    {
        require_code('temporal');

        if ($for_kpi !== null) {
            if (($for_kpi) && (!$this->for_kpi)) {
                $current_month = to_epoch_interval_index(time(), 'months');
                $this->default = [$current_month - $default - 1, $current_month];
                return;
            } elseif ((!$for_kpi) && ($this->for_kpi)) {
                list($start, $end) = $default;
                $this->default = ($end - $start) + 1;
                return;
            }
        }

        $this->default = $default;
    }

    /**
     * Read in and/or post-process filter value.
     *
     * @param  array $filters An array of our current filters; passed by reference and modified as they are read in
     * @param  ?boolean $for_kpi Whether we want this for a KPI (null: an object was initiated)
     * @return mixed The filter
     */
    public function read_value(array &$filters, ?bool $for_kpi = null)
    {
        require_code('temporal');

        if (isset($filters[$this->filter_name])) {
            $default = $filters[$this->filter_name];
        } else {
            $default = $this->default;
        }

        $ret = mixed();

        if ($this->for_kpi) {
            $ret = either_param_integer($this->filter_name, $default);

            $wants_for_non_kpi = ($for_kpi !== null) && (!$for_kpi);
            if ($wants_for_non_kpi) {
                $current_month = to_epoch_interval_index(time(), 'months');
                $ret = [$current_month - $ret - 1, $current_month];
            }

            $filters[$this->filter_name] = $ret;
            return $ret;
        }

        $start = either_param_integer($this->filter_name . '__start', $default[0]);
        $end = either_param_integer($this->filter_name . '__end', $default[1]);

        if ($end <= 0) { // An end of 0 means now; negative values mean abs(value) months into the future
            $end = to_epoch_interval_index(time(), 'months') + abs($end);
        }

        if ($start < 0) { // Negative start means we go back that many months in the past from end
            $start = $end + $start;
        }

        $wants_for_kpi = ($for_kpi !== null) && ($for_kpi);
        if ($wants_for_kpi) {
            $ret = ($end - $start) + 1;
        } else {
            $ret = [$start, $end];
        }

        $filters[$this->filter_name] = $ret;
        return $ret;
    }
}

/**
 * Class for stat date input filters.
 *
 * @package stats
 */
class CMSStatsDatePivot extends CMSStatsFilter
{
    protected $pivot_values;

    /**
     * Constructor.
     *
     * @param  string $filter_name Filter name
     * @param  array $pivot_values List of possible pivot values
     * @param  string $default Default
     */
    public function __construct(string $filter_name, array $pivot_values, string $default = 'day_series')
    {
        $this->filter_name = $filter_name;
        $this->label = do_lang_tempcode('TALLY_BY');
        $this->default = $default;
        $this->pivot_values = $pivot_values;
    }

    /**
     * Get the inputting UI for the filter.
     *
     * @param  Tempcode $hidden The hidden field
     * @return Tempcode The input field
     */
    public function ui_component(object &$hidden) : object
    {
        require_code('form_templates');
        $list = new Tempcode();
        $filters = [];
        foreach ($this->pivot_values as $key => $val) {
            $list->attach(form_input_list_entry($key, $key == $this->read_value($filters), $val));
        }
        return form_input_list($this->label, new Tempcode(), $this->filter_name, $list);
    }

    /**
     * Get the pivot values set on this filter.
     *
     * @return array The pivot values
     */
    public function get_pivot_values() : array
    {
        return $this->pivot_values;
    }
}

/**
 * Find whether geolocation data is installed.
 *
 * @return boolean Whether it is
 */
function has_geolocation_data() : bool
{
    return ($GLOBALS['SITE_DB']->query_select_value_if_there('ip_country', 'begin_num') !== null);
}

/**
 * Log a stats event.
 *
 * @param  string $event The event
 */
function log_stats_event(string $event)
{
    if ((get_option('site_closed') != '0') && (get_option('stats_when_closed') == '0')) {
        return;
    }

    require_code('locations');

    $country_code = geolocate_ip();
    $GLOBALS['SITE_DB']->query_insert('stats_events', [
        'e_event' => $event,
        'e_date_and_time' => time(),
        'e_country_code' => ($country_code == '') ? '' : $country_code,
        'e_session_id' => get_session_id(),
    ]);
}

/**
 * Cleanup old stats.
 */
function cleanup_stats()
{
    if (!$GLOBALS['SITE_DB']->table_is_locked('stats')) {
        $cutoff = strval(time() - 60 * 60 * 24 * intval(get_option('stats_store_time')));

        $where = 'date_and_time<' . $cutoff;
        $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'stats WHERE ' . $where, 500/*to reduce lock times*/, 0, true); // Errors suppressed in case DB write access broken

        $where = 'e_date_and_time<' . $cutoff;
        $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'stats_events WHERE ' . $where, 500/*to reduce lock times*/, 0, true); // Errors suppressed in case DB write access broken

        $where = 'c_date_and_time<' . $cutoff;
        $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'stats_link_tracker WHERE ' . $where, 500/*to reduce lock times*/, 0, true); // Errors suppressed in case DB write access broken
    }
}

/**
 * Pre-process raw data for a stats hook.
 *
 * @param  string $hook_name The hook name to preprocess data in
 * @param  TIME $start_time Start time
 * @param  ?TIME $end_time End time (null: now)
 */
function preprocess_raw_data_for(string $hook_name, int $start_time = 0, ?int $end_time = null)
{
    require_code('temporal');

    if ($end_time === null) {
        $end_time = time();
    }

    require_code('hooks/modules/admin_stats/' . filter_naughty($hook_name));
    $hook_ob = object_factory('Hook_admin_stats_' . $hook_name, true);
    if ($hook_ob === null) {
        return;
    }

    $info = $hook_ob->info();
    if ($info === null) {
        return;
    }

    if (!$hook_ob instanceof CMSStatsProvider) {
        return;
    }

    cms_profile_start_for('preprocess_raw_data_for::' . $hook_name);

    // First we need to load up any data we already processed for any months within the time range, so anything new will MERGE into that...

    $months = [];
    for ($timestamp = $start_time; $timestamp < $end_time; $timestamp += 60 * 60 * 24 * 28) {
        $month = to_epoch_interval_index($timestamp, 'months');
        $months[$month] = true;
    }

    cms_profile_start_for('preprocess_raw_data_for::' . $hook_name . '->preprocess_raw_data');

    $data_buckets = [];
    foreach (array_keys($info) as $bucket) {
        $data_buckets[$bucket] = [];

        foreach (array_keys($months) as $month) {
            $data_rows = $GLOBALS['SITE_DB']->query_select('stats_preprocessed', ['p_pivot', 'p_data'], [
                'p_bucket' => $bucket,
                'p_month' => $month,
            ]);
            foreach ($data_rows as $row) {
                $pivot = $row['p_pivot'];
                $data_buckets[$bucket][$month][$pivot] = @unserialize($row['p_data']);
                if ($data_buckets[$bucket][$month][$pivot] === false) {
                    $data_buckets[$bucket][$month][$pivot] = [];
                }
            }
        }
    }

    // Preprocess new data...

    $hook_ob->preprocess_raw_data($start_time, $end_time, $data_buckets);

    // Re-save into the database...

    foreach ($data_buckets as $bucket => $_) {
        foreach ($_ as $month => $__) {
            foreach ($__ as $pivot => $data) {
                $GLOBALS['SITE_DB']->query_insert_or_replace('stats_preprocessed', [
                    'p_data' => serialize($data),
                ], [
                    'p_bucket' => $bucket,
                    'p_month' => $month,
                    'p_pivot' => $pivot,
                ]);
            }
        }
    }

    unset($data_buckets);

    cms_profile_end_for('preprocess_raw_data_for::' . $hook_name . '->preprocess_raw_data');

    // Now for flat data...

    cms_profile_start_for('preprocess_raw_data_for::' . $hook_name . '->preprocess_raw_data_flat');

    // First we need to load up any data we already processed for any months within the time range, so anything new will MERGE into that...

    $data_buckets_flat = [];
    foreach (array_keys($info) as $bucket) {
        $p_data = $GLOBALS['SITE_DB']->query_select_value_if_there('stats_preprocessed_flat', 'p_data', [
            'p_bucket' => $bucket,
        ]);
        if ($p_data !== null) {
            $data_buckets_flat[$bucket] = @unserialize($p_data);
            if ($data_buckets_flat[$bucket] === false) {
                $data_buckets_flat[$bucket] = [];
            }
        }
    }

    // Preprocess new data...

    $hook_ob->preprocess_raw_data_flat($start_time, $end_time, $data_buckets_flat);

    // Re-save into the database...

    foreach ($data_buckets_flat as $bucket => $data) {
        $GLOBALS['SITE_DB']->query_insert_or_replace('stats_preprocessed_flat', [
            'p_data' => serialize($data),
        ], [
            'p_bucket' => $bucket,
        ]);
    }

    unset($data_buckets_flat);

    cms_profile_end_for('preprocess_raw_data_for::' . $hook_name . '->preprocess_raw_data_flat');

    cms_profile_end_for('preprocess_raw_data_for::' . $hook_name);
}

/**
 * Send out KPI notifications, as appropriate based on today's date.
 */
function send_kpi_notifications()
{
    cms_profile_start_for('send_kpi_notifications');

    $series = [ // Runs day after a period ends
        'year_series' => (date('m-d') == '01-01'),
        'quarter_series' => (date('d') == '01') && (in_array(date('m'), ['01', '04', '07', '10'])),
        'month_series' => (date('d') == '01'),
        'day_series' => true,
    ];

    $kpis = [];
    foreach ($series as $pivot => $send_today) {
        if ($send_today) {
            $_kpis = gather_kpis($pivot);
            $kpis = array_merge($kpis, $_kpis);
        }
    }

    if (!empty($kpis)) {
        $kpis_for_tpl = [];
        foreach ($kpis as $kpi) {
            list($kpi_row, , , , , , $edit_url, , $target, $current, $hits_target) = $kpi;
            $graph_name = $kpi_row['k_graph_name'];
            $edit_url = build_url(['page' => 'admin_stats', 'type' => '_edit', 'id' => $kpi_row['id']], get_module_zone('admin_stats'));
            $view_url = build_url(['page' => 'admin_stats', 'type' => 'kpis'], get_module_zone('admin_stats'), [], false, false, false, 'graph_' . $graph_name);
            $username = $GLOBALS['FORUM_DRIVER']->get_username($kpi_row['k_submitter'], true);
            $kpis_for_tpl[] = [
                'TITLE' => $kpi_row['k_title'],
                'CURRENT' => ($current === null) ? null : (is_integer($current) ? integer_format($current, 0) : float_format($current, 4, true)),
                'HITS_TARGET' => $hits_target,
                'TARGET' => ($target === null) ? null : float_format($target, 4, true),
                'EDIT_URL' => $edit_url,
                'VIEW_URL' => $view_url,
                'GRAPH_NAME' => $kpi_row['k_graph_name'],
                'USERNAME' => $username,
                'ADDED' => get_timezoned_date($kpi_row['k_added_time']),
                'NOTES' => $kpi_row['k_notes'],
            ];
        }

        require_code('notifications');
        $mail = do_notification_template('KPI_UPDATE_MAIL', [
            'KPIS' => $kpis_for_tpl,
        ], null, false, null, '.txt', 'text');

        dispatch_notification('kpis', '', do_lang('NOTIFICATION_TYPE_kpis'), $mail->evaluate(get_site_default_lang()));
    }

    cms_profile_end_for('send_kpi_notifications');
}
