<?php

namespace SlimStat\Utils;

use InvalidArgumentException;

class Query
{
    private $queries = [];

    private $operation;

    private $table;

    private $fields = '*';

    private $orderClause;

    private $groupByClause;

    private $havingClauses = [];

    private $limitClause;

    private $whereRelation = 'AND';

    private $setClauses = [];

    private $setValuesToPrepare = [];

    private $joinClauses = [];

    private $whereClauses = [];

    private $rawWhereClause = [];

    private $valuesToPrepare = [];

    private $insertValues = [];

    private $ignore = false;

    private $allowCaching = false;

    private $cacheExpiration = 3600;

    protected $db;

    private $_isLiveQuery = false;

    private static $processingTimestamp = null;

    /**
     * Constructor.
     *
     * Initializes the query with the global $wpdb instance.
     */
    public function __construct()
    {
        $this->db = \wp_slimstat::$wpdb ?? $GLOBALS['wpdb'];
    }

    /**
     * Initializes a new query instance for a select operation on a table.
     *
     * @param string|array $fields The fields to select. If an array is provided, the fields are
     *                             concatenated with a comma separator and the resulting string
     *                             is used as the SELECT clause.
     *
     * @return static A new Query instance configured for a select operation.
     */
    public static function select($fields = '*')
    {
        $instance            = new self();
        $instance->operation = 'select';
        $instance->fields    = is_array($fields) ? implode(', ', $fields) : $fields;
        return $instance;
    }

    /**
     * Initializes a new query instance for an update operation on the specified table.
     *
     * @param string $table The name of the table to update.
     *
     * @return static A new Query instance configured for an update operation.
     */
    public static function update($table)
    {
        $instance            = new self();
        $instance->operation = 'update';
        $instance->table     = $table;
        return $instance;
    }

    /**
     * Initializes a new query instance for a delete operation on the specified table.
     *
     * @param string $table The name of the table to delete from.
     *
     * @return static
     */
    public static function delete($table)
    {
        $instance            = new self();
        $instance->operation = 'delete';
        $instance->table     = $table;
        return $instance;
    }

    /**
     * Initializes a new query instance for an insert operation on the specified table.
     *
     * @param string $table The name of the table to insert data into.
     *
     * @return self A new Query instance configured for an insert operation.
     */
    public static function insert($table)
    {
        $instance            = new self();
        $instance->operation = 'insert';
        $instance->table     = $table;
        return $instance;
    }

    /**
     * Adds IGNORE to the query.
     *
     * @param bool $ignore
     * @return $this
     */
    public function ignore($ignore = true)
    {
        $this->ignore = $ignore;
        return $this;
    }

    /**
     * Combines multiple query instances into a single UNION query.
     *
     * @param array $queries An array of Query instances to be united.
     *
     * @return self A new Query instance representing the UNION of the provided queries.
     */
    public static function union($queries)
    {
        $instance            = new self();
        $instance->operation = 'union';
        $instance->queries   = $queries;
        return $instance;
    }

    /**
     * Specifies the table to be used in the query.
     *
     * @param string $table The name of the table to use.
     *
     * @return $this
     */
    public function from($table)
    {
        $this->table = $table;
        return $this;
    }

    /**
     * Sets the values for an insert operation.
     *
     * @return $this
     */
    public function values(array $values)
    {
        if ($values === []) {
            return $this;
        }

        // Check if it's an array of arrays for bulk insert
        if (isset($values[0]) && is_array($values[0])) {
            // Bulk insert
            $this->insertValues = $values;
        } else {
            // Single row insert
            $this->insertValues[] = $values;
        }

        return $this;
    }

    /**
     * Sets the values for the columns in the current query.
     *
     * This function prepares the column assignments for an SQL update operation.
     * It supports string, numeric, and null values, and automatically escapes
     * field names to prevent SQL injection.
     *
     * @param array $values An associative array of column-value pairs to set.
     *                      The array key is the column name, and the value is
     *                      the value to assign to the column.
     *
     * @return $this
     */
    public function set($values)
    {
        if (empty($values)) {
            return $this;
        }

        foreach ($values as $field => $value) {
            $column = '`' . str_replace('`', '``', $field) . '`';
            if (is_string($value)) {
                $this->setClauses[]           = sprintf('%s = %%s', $column);
                $this->setValuesToPrepare[]   = $value;
            } elseif (is_numeric($value)) {
                $this->setClauses[]           = sprintf('%s = %%s', $column);
                $this->setValuesToPrepare[]   = $value;
            } elseif (is_null($value)) {
                $this->setClauses[] = $column . ' = NULL';
            }
        }

        return $this;
    }

    /**
     * Sets a raw value for a column, allowing for SQL expressions.
     *
     * @param string $column
     * @param string $expression
     * @param array $params
     * @return $this
     */
    public function setRaw($column, $expression, $params = [])
    {
        $this->setClauses[] = sprintf('`%s` = %s', str_replace('`', '``', $column), $expression);
        if (!empty($params)) {
            $this->setValuesToPrepare = array_merge($this->setValuesToPrepare, $params);
        }

        return $this;
    }

    /**
     * Add a WHERE clause to the query.
     *
     * @param string $field    The field to filter on.
     * @param string $operator The operator to use. Supported operators: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN.
     * @param mixed  $value    The value to filter on. Can be a string, int, array or null.
     *
     * @return $this
     *
     * @throws InvalidArgumentException If the operator is not supported.
     */
    public function where($field, $operator, $value)
    {
        if ('BETWEEN' === strtoupper($operator) && is_array($value) && 2 === count($value) && (null !== $value[0] && null !== $value[1])) {
            $condition = $this->generateCondition($field, $operator, $value);
            if (!empty($condition)) {
                $this->whereClauses[]  = $condition['condition'];
                $this->valuesToPrepare = array_merge($this->valuesToPrepare, $condition['values']);
            }

            return $this;
        }

        if (is_array($value)) {
            $value = array_filter(array_values($value));
        }

        if (!is_numeric($value) && empty($value)) {
            return $this;
        }

        $condition = $this->generateCondition($field, $operator, $value);
        if (!empty($condition)) {
            $this->whereClauses[]  = $condition['condition'];
            $this->valuesToPrepare = array_merge($this->valuesToPrepare, $condition['values']);
        }

        return $this;
    }

    /**
     * Add a raw WHERE clause to the query. If values are provided, they will be
     * escaped and inserted into the query.
     *
     * @param string $condition The raw WHERE condition.
     * @param array  $values    Values to be inserted into the condition.
     *
     * @return $this
     */
    public function whereRaw($condition, $values = [])
    {
        $this->rawWhereClause[] = empty($values) ? $condition : $this->prepareQuery($condition, $values);

        return $this;
    }

    /**
     * Add a raw HAVING clause to the query. If values are provided, they will be
     * escaped and inserted into the clause. Any leading "HAVING" keyword is stripped
     * to ensure correct placement in the final query.
     *
     * @param string $condition The raw HAVING condition.
     * @param array  $values    Values to be inserted into the condition.
     *
     * @return $this
     */
    public function havingRaw($condition, $values = [])
    {
        // Strip an optional leading HAVING keyword to avoid duplication
        $condition = preg_replace('/^\s*HAVING\s+/i', '', $condition);
        $this->havingClauses[] = empty($values) ? $condition : $this->prepareQuery($condition, $values);

        return $this;
    }

    /**
     * Sets the GROUP BY clause for the query.
     *
     * @param string|array $fields The fields to group by. Can be a comma-separated string or an array of fields.
     *
     * @return $this
     */
    public function groupBy($fields)
    {
        if (is_array($fields)) {
            $fields = implode(', ', $fields);
        }

        if (!empty($fields)) {
            $this->groupByClause = 'GROUP BY ' . $fields;
        }

        return $this;
    }

    /**
     * Sets the ORDER BY clause for the query.
     *
     * @param string|array $fields The fields to order by. Can be a comma-separated string or an array of fields.
     * @param string       $order  The order direction, either 'ASC' or 'DESC'. Defaults to 'DESC'.
     *
     * @return $this
     */
    public function orderBy($fields, $order = 'DESC')
    {
        if (empty($fields)) {
            return $this;
        }

        if (is_string($fields)) {
            if (preg_match('/\b(ASC|DESC)\b/i', $fields)) {
                $this->orderClause = 'ORDER BY ' . $fields;
                return $this;
            }

            $fields = explode(',', $fields);
            $fields = array_map('trim', $fields);
        }

        if (is_array($fields)) {
            $order = strtoupper($order);
            if (!in_array($order, ['ASC', 'DESC'])) {
                $order = 'DESC';
            }

            $orderParts = [];
            foreach ($fields as $field) {
                $orderParts[] = sprintf('%s %s', $field, $order);
            }

            $this->orderClause = 'ORDER BY ' . implode(', ', $orderParts);
        }

        return $this;
    }

    /**
     * Sets the LIMIT clause for the query, with an optional OFFSET.
     *
     * @param int $limit  The maximum number of results to return.
     * @param int $offset The number of rows to skip. Defaults to 0.
     *
     * @return $this
     */
    public function limit($limit, $offset = 0)
    {
        $limit  = intval($limit);
        $offset = intval($offset);
        if ($offset > 0) {
            $this->limitClause = sprintf('LIMIT %d OFFSET %d', $limit, $offset);
        } else {
            $this->limitClause = 'LIMIT ' . $limit;
        }
        return $this;
    }

    /**
     * Sets the LIMIT and OFFSET clauses for pagination.
     *
     * @param int $page    The page number. Defaults to 1.
     * @param int $perPage The number of results to show per page. Defaults to 10.
     *
     * @return $this
     */
    public function perPage($page = 1, $perPage = 10)
    {
        $page    = intval($page);
        $perPage = intval($perPage);
        if ($page > 0 && $perPage > 0) {
            $offset            = ($page - 1) * $perPage;
            $this->limitClause = sprintf('LIMIT %d OFFSET %d', $perPage, $offset);
        }

        return $this;
    }

    /**
     * Join another table.
     *
     * @param string       $table      The table to join.
     * @param string|array $on         The join condition. Can be an array with two fields to join on, or a string with a condition.
     * @param array        $conditions An array of conditions to join on. Each condition is an array with three elements: field, operator, value.
     * @param string       $joinType   The type of join. Can be INNER, LEFT, or RIGHT. Defaults to INNER.
     *
     * @return $this
     *
     * @throws InvalidArgumentException If the join condition is invalid.
     */
    public function join($table, $on, $conditions = [], $joinType = 'INNER')
    {
        $joinType = strtoupper($joinType);
        if (is_array($on) && 2 == count($on)) {
            $joinClause = sprintf('%s JOIN %s ON %s = %s', $joinType, $table, $on[0], $on[1]);
            if (!empty($conditions)) {
                foreach ($conditions as $condition) {
                    $field    = $condition[0];
                    $operator = $condition[1];
                    $value    = $condition[2];
                    $cond     = $this->generateCondition($field, $operator, $value);
                    if (!empty($cond)) {
                        $joinClause .= ' AND ' . $cond['condition'];
                        $this->valuesToPrepare = array_merge($this->valuesToPrepare, $cond['values']);
                    }
                }
            }

            $this->joinClauses[] = $joinClause;
            return $this;
        }

        // Backward compatibility: allow two string fields passed separately
        if (is_string($on) && is_string($conditions) && '' !== $on && '' !== $conditions) {
            $this->joinClauses[] = sprintf('%s JOIN %s ON %s = %s', $joinType, $table, $on, $conditions);
            return $this;
        }

        // Allow raw ON condition string
        if (is_string($on) && '' !== $on && (empty($conditions) || (is_array($conditions) && empty($conditions)))) {
            $this->joinClauses[] = sprintf('%s JOIN %s ON %s', $joinType, $table, $on);
            return $this;
        }

        throw new InvalidArgumentException('Invalid join clause');
    }

    /**
     * Set the caching flag and expiration time.
     *
     * @param bool $flag       Whether to allow caching.
     * @param int  $expiration The cache expiration time in seconds.
     *
     * @return $this
     */
    public function allowCaching($flag = true, $expiration = 3600)
    {
        $this->allowCaching    = $flag;
        $this->cacheExpiration = $expiration;
        return $this;
    }

    /**
     * Set the caching flag depending on whether the given date range overlaps with today.
     *
     * If the given date range is entirely in the past, caching is allowed. Otherwise, caching is disabled.
     *
     * @param int|string $to The end date of the range (Y-m-d or Y-m-d H:i:s or timestamp)
     *
     * @return $this
     */
    public function canUseCacheForDateRange($to)
    {
        $today = $this->getTodayDate();
        $toTs  = is_numeric($to) ? intval($to) : strtotime($to);

        if ($toTs < $today) {
            $this->allowCaching(true, $this->cacheExpiration);
        } else {
            $this->allowCaching(false);
        }
    }

    /**
     * Set the processing timestamp context for caching decisions.
     * This should be set to the timestamp of the event being processed (e.g., $stat['dt'])
     * to ensure caching decisions are based on the event time, not the current server time.
     *
     * @param int|null $timestamp Unix timestamp of the event being processed, or null to use current time.
     * @return void
     */
    public static function setProcessingTimestamp($timestamp)
    {
        self::$processingTimestamp = $timestamp;
    }

    /**
     * Get the timestamp for the start of today.
     * If a processing timestamp has been set, calculates "today" based on that timestamp.
     * Otherwise, uses the current server time.
     *
     * @return int The timestamp for the start of today (midnight).
     */
    protected function getTodayDate()
    {
        if (null !== self::$processingTimestamp) {
            return strtotime(date('Y-m-d 00:00:00', self::$processingTimestamp));
        }
        return strtotime(date('Y-m-d 00:00:00'));
    }

    protected function getCacheKey($input)
    {
        $normalized = $input;
        if (preg_match('/BETWEEN\s+[\'\"]?(\d{4}-\d{2}-\d{2})[\s\d:]*[\'\"]?\s+AND\s+[\'\"]?(\d{4}-\d{2}-\d{2})[\s\d:]*[\'\"]?/i', $input, $matches)) {
            $from       = $matches[1];
            $to         = $matches[2];
            $normalized = preg_replace('/BETWEEN\s+[\'\"]?(\d{4}-\d{2}-\d{2})[\s\d:]*[\'\"]?\s+AND\s+[\'\"]?(\d{4}-\d{2}-\d{2})[\s\d:]*[\'\"]?/i', sprintf("BETWEEN '%s' AND '%s'", $from, $to), $input);
        }

        $normalized = preg_replace_callback('/(\d{4}-\d{2}-\d{2})[\s\d:]{0,8}/', fn ($m) => $m[1], $normalized);
        $hash       = substr(md5($normalized), 0, 10);
        return sprintf('wp_slimstat_cache_%s', $hash);
    }

    protected function getCachedResult($input)
    {
        $cacheKey = $this->getCacheKey($input);
        return get_transient($cacheKey);
    }

    protected function setCachedResult($input, $result, $expiration = DAY_IN_SECONDS)
    {
        $cacheKey = $this->getCacheKey($input);
        return set_transient($cacheKey, $result, $expiration);
    }

    /**
     * Analyzes the WHERE clauses to detect date ranges that overlap with today.
     *
     * This function iterates through the WHERE clauses to find any clause that specifies
     * a date range (using "BETWEEN %s AND %s") and determines if this range overlaps
     * with today. It extracts the timestamps for the start and end of the historical
     * period (up to the start of today) and the live period (starting today).
     *
     * @return array<int|bool|null> An array containing:
     *                              - boolean: whether a split range was found
     *                              - int|null: historical start timestamp
     *                              - int|null: historical end timestamp (inclusive)
     *                              - int|null: live start timestamp (inclusive)
     *                              - int|null: live end timestamp
     */
    protected function getSplitDateRanges2()
    {
        $dtField    = 'dt';
        $todayStart = $this->getTodayDate();
        foreach ($this->whereClauses as $idx => $clause) {
            if (preg_match('/' . $dtField . ' BETWEEN %s AND %s/', $clause)) {
                $from  = null;
                $to    = null;
                $dtIdx = 0;
                foreach ($this->whereClauses as $i => $c) {
                    if ($i == $idx) {
                        break;
                    }

                    if (preg_match('/%s/', $c)) {
                        $dtIdx += substr_count($c, '%s');
                    }
                }

                $from   = $this->valuesToPrepare[$dtIdx] ?? null;
                $to     = $this->valuesToPrepare[$dtIdx + 1] ?? null;
                $fromTs = is_numeric($from) ? intval($from) : strtotime($from);
                $toTs   = is_numeric($to) ? intval($to) : strtotime($to);
                if (null !== $fromTs && null !== $toTs && $fromTs < $todayStart && $toTs >= $todayStart) {
                    return [true, $fromTs, $todayStart - 1, $todayStart, $toTs];
                }
            }
        }

        return [false, null, null, null, null];
    }

    /**
     * Helper: Generate a SQL condition based on the given field, operator and value.
     *
     * Supported operators: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN
     *
     * @param string $field    Field name
     * @param string $operator SQL operator
     * @param mixed  $value    Value to be used in the condition. Can be a string, int, array or null.
     *
     * @return array|false Array with keys 'condition' and 'values', or false if the condition could not be generated.
     *
     * @throws InvalidArgumentException If the operator is not supported.
     */
    protected function generateCondition($field, $operator, $value)
    {
        $condition = '';
        $values    = [];
        switch ($operator) {
            case '=':
            case '!=':
            case '>':
            case '>=':
            case '<':
            case '<=':
            case 'LIKE':
            case 'NOT LIKE':
                $condition = sprintf('%s %s %%s', $field, $operator);
                $values[]  = $value;
                break;
            case 'IS':
            case 'IS NOT':
                if (is_null($value)) {
                    $condition = sprintf('%s %s NULL', $field, $operator);
                }

                break;
            case 'IN':
            case 'NOT IN':
                if (is_string($value)) {
                    $value = explode(',', $value);
                }

                if (!empty($value) && is_array($value) && 1 == count($value)) {
                    $operator = ('IN' === $operator) ? '=' : '!=';
                    return $this->generateCondition($field, $operator, reset($value));
                }

                if (!empty($value) && is_array($value)) {
                    $placeholders = implode(', ', array_fill(0, count($value), '%s'));
                    $condition    = sprintf('%s %s (%s)', $field, $operator, $placeholders);
                    $values       = $value;
                }

                break;
            case 'BETWEEN':
                if (is_array($value) && 2 === count($value)) {
                    $condition = sprintf('%s BETWEEN %%s AND %%s', $field);
                    $values    = $value;
                }

                break;
            default:
                throw new InvalidArgumentException('Unsupported operator: ' . $operator);
        }

        if ('' === $condition || '0' === $condition) {
            return null;
        }

        return [
            'condition' => $condition,
            'values'    => $values,
        ];
    }

    /**
     * Builds and returns an SQL query string based on the current operation and clauses.
     *
     * This function constructs a SQL query by assembling various parts such as the
     * operation type (select, update, delete, insert, union), join clauses, where
     * clauses, group by, order by, and limit clauses. It supports conditional logic
     * to append appropriate SQL syntax based on the operation and provided clauses.
     *
     * @return string The constructed SQL query string.
     *
     * @throws InvalidArgumentException If the operation type is unknown.
     */
    protected function buildQuery()
    {
        switch ($this->operation) {
            case 'select':
                $query = sprintf('SELECT %s FROM %s', $this->fields, $this->table);
                break;
            case 'update':
                $operation = $this->ignore ? 'UPDATE IGNORE' : 'UPDATE';
                $query = sprintf('%s %s SET ', $operation, $this->table) . implode(', ', $this->setClauses);
                break;
            case 'delete':
                $query = 'DELETE FROM ' . $this->table;
                break;
            case 'insert':
                if (empty($this->insertValues)) {
                    return '';
                }

                $operation = $this->ignore ? 'INSERT IGNORE INTO' : 'INSERT INTO';
                $sampleRow = $this->insertValues[0];
                $keys      = array_keys($sampleRow);
                $query     = sprintf('%s %s (`%s`) VALUES ', $operation, $this->table, implode('`, `', $keys));

                $valueSets = [];
                foreach ($this->insertValues as $row) {
                    $placeholders  = implode(', ', array_fill(0, count($row), '%s'));
                    $valueSets[]   = '(' . $placeholders . ')';
                    foreach ($row as $value) {
                        $this->valuesToPrepare[] = $value;
                    }
                }

                $query .= implode(', ', $valueSets);
                break;
            case 'union':
                $query = implode(' UNION ', $this->queries);
                break;
            default:
                throw new InvalidArgumentException('Unknown operation');
        }

        if (!empty($this->joinClauses)) {
            $query .= ' ' . implode(' ', $this->joinClauses);
        }

        if (!empty($this->whereClauses)) {
            $query .= ' WHERE ' . implode(sprintf(' %s ', $this->whereRelation), $this->whereClauses);
        }

        if (!empty($this->rawWhereClause)) {
            $wrappedClauses = array_map(fn($clause) => "($clause)", $this->rawWhereClause);
            if (!empty($this->whereClauses)) {
                $query .= ' AND ' . implode(' AND ', $wrappedClauses);
            } else {
                $query .= ' WHERE ' . implode(' AND ', $wrappedClauses);
            }
        }

        if (!empty($this->groupByClause)) {
            $query .= ' ' . $this->groupByClause;
        }

        if (!empty($this->havingClauses)) {
            $query .= ' HAVING ' . implode(' AND ', $this->havingClauses);
        }

        if (!empty($this->orderClause)) {
            $query .= ' ' . $this->orderClause;
        }

        if (!empty($this->limitClause)) {
            $query .= ' ' . $this->limitClause;
        }

        return $query;
    }

    /**
     * Prepares a query for execution by replacing placeholders with actual values.
     * Supported placeholders are %i, %s, %f, and %d.
     * If the query contains more than one placeholder, the $args parameter should be an array with the same number of elements.
     * If the query contains only one placeholder, the $args parameter can be either an array or a single value.
     * If the query contains no placeholders, the $args parameter is ignored.
     *
     * @param string $query
     * @param array  $args
     *
     * @return string The prepared query
     */
    protected function prepareQuery($query, $args = [])
    {
        if (preg_match('/%[i|s|f|d]/', $query)) {
            $placeholder_count = preg_match_all('/%[i|s|f|d]/', $query, $matches);
            $args_count        = is_array($args) ? count($args) : (empty($args) ? 0 : 1);
            if (1 === $placeholder_count) {
                $query = is_array($args) ? $this->db->prepare($query, reset($args)) : $this->db->prepare($query, $args);
            } elseif (is_array($args) && $args_count === $placeholder_count) {
                $query = $this->db->prepare($query, $args);
            } else {
                return $query;
            }
        }

        return $query;
    }

    /**
     * Generates a cache key for a given query and its arguments.
     *
     * This method serializes the query and arguments into a data array,
     * creates an MD5 hash of the serialized data, and returns a truncated
     * hash as a unique cache key prefixed with 'wp_slimstat_query_'.
     *
     * @param string $query The SQL query.
     * @param array  $args  The query arguments.
     *
     * @return string The generated cache key.
     */
    protected function getCacheKeyForQuery($query, $args = [])
    {
        $data = [
            'query' => $query,
            'args'  => $args,
        ];
        $hash = substr(md5(serialize($data)), 0, 16);
        return 'wp_slimstat_query_' . $hash;
    }

    /**
     * Retrieves the cached result for the given query and args
     *
     * @param string $query The SQL query
     * @param array  $args  The query arguments
     *
     * @return mixed The query result, or false if there is no cached result
     */
    protected function getCachedResultForQuery($query, $args = [])
    {
        $cacheKey = $this->getCacheKeyForQuery($query, $args);
        $data     = get_transient($cacheKey);
        if (false === $data) {
            return false;
        }

        if (is_array($data) && isset($data['chunks']) && isset($data['size'])) {
            $chunks = [];
            for ($i = 0; $i < $data['chunks']; $i++) {
                $chunk = get_transient($cacheKey . '_' . $i);
                if (false === $chunk) {
                    return false;
                }

                $chunks[] = $chunk;
            }

            $data = implode('', $chunks);
        } elseif (is_array($data)) {
            // Data is already an array (from transient), return directly
            return $data;
        }

        if (function_exists('gzuncompress') && is_string($data)) {
            $first2 = substr($data, 0, 2);
            if ("\x1f\x8b" === $first2 || "\x78\x9c" === $first2 || "\x78\xda" === $first2) {
                $data = @gzuncompress($data);
            }
        }

        // Use JSON decode instead of unserialize for security
        $decoded = json_decode($data, true);
        if (json_last_error() === JSON_ERROR_NONE) {
            return $decoded;
        }

        // Return false if JSON decode failed (corrupted or legacy data)
        return false;
    }

    /**
     * Sets the transient cache for the given query and args
     *
     * @param string $query      The SQL query
     * @param array  $args       The query arguments
     * @param mixed  $result     The query result
     * @param int    $expiration The cache expiration time, in seconds
     *
     * @return bool True if cache was successfully set, false otherwise
     */
    protected function setCachedResultForQuery($query, $args, $result, $expiration = 300)
    {
        $cacheKey = $this->getCacheKeyForQuery($query, $args);
        // Use JSON encode instead of serialize for security
        $data     = wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (false === $data) {
            return false;
        }

        $max_chunk_size = 900 * 1024; // 900KB
        $old_meta       = get_transient($cacheKey);
        if (is_array($old_meta) && isset($old_meta['chunks'])) {
            for ($i = 0; $i < $old_meta['chunks']; $i++) {
                delete_transient($cacheKey . '_' . $i);
            }
        }

        if (strlen($data) > $max_chunk_size) {
            $chunks = str_split($data, $max_chunk_size);
            $meta   = [
                'chunks' => count($chunks),
                'size'   => strlen($data),
            ];
            if (strlen(wp_json_encode($meta)) > $max_chunk_size) {
                return false;
            }

            set_transient($cacheKey, $meta, $expiration);
            foreach ($chunks as $i => $chunk) {
                set_transient($cacheKey . '_' . $i, $chunk, $expiration);
            }
        } else {
            set_transient($cacheKey, $data, $expiration);
        }

        return true;
    }

    /**
     * Extracts a date range from the WHERE clause where the range overlaps with today.
     * Returns an array with the following elements:
     * - boolean: whether a split range was found
     * - int: historical start timestamp
     * - int: historical end timestamp (inclusive)
     * - int: live start timestamp (inclusive)
     * - int: live end timestamp
     * - int: index of the date field in the WHERE clause
     * - int: index of the WHERE clause with the date range
     *
     * @return array<int, int, int, int, int, int, int>
     */
    protected function getSplitDateRanges()
    {
        $dtField    = 'dt';
        $todayStart = $this->getTodayDate();
        foreach ($this->whereClauses as $idx => $clause) {
            if (preg_match('/' . $dtField . ' BETWEEN %s AND %s/', $clause)) {
                $dtIdx = 0;
                foreach ($this->whereClauses as $i => $c) {
                    if ($i == $idx) {
                        break;
                    }

                    if (preg_match('/%s/', $c)) {
                        $dtIdx += substr_count($c, '%s');
                    }
                }

                $from   = $this->valuesToPrepare[$dtIdx] ?? null;
                $to     = $this->valuesToPrepare[$dtIdx + 1] ?? null;
                $fromTs = is_numeric($from) ? intval($from) : strtotime($from);
                $toTs   = is_numeric($to) ? intval($to) : strtotime($to);
                if (null !== $fromTs && null !== $toTs && $fromTs < $todayStart && $toTs >= $todayStart) {
                    return [true, $fromTs, $todayStart - 1, $todayStart, $toTs, $dtIdx, $idx];
                }
            }
        }

        return [false, null, null, null, null, null, null];
    }

    /**
     * Merges two arrays of result rows from the historical and live parts of a query.
     * If $groupKey is set, the function will group the results by this key and sum the
     * values in $sumFields for each group. Otherwise, the function will return the
     * array merge of the two arrays.
     *
     * @param array  $historical The result rows from the historical part of the query
     * @param array  $live       The result rows from the live part of the query
     * @param string $groupKey   The key to group the results by
     * @param array  $sumFields  The fields to sum for each group
     *
     * @return array The merged and grouped result rows
     */
    protected function mergeGroupResults($historical, $live, $groupKey = null, $sumFields = ['counthits'])
    {
        $historical = is_array($historical) ? $historical : [];
        $live       = is_array($live) ? $live : [];

        // If no group key provided, try to determine it from the data
        if (!$groupKey) {
            // Try to find a suitable group key from the first row
            $firstRow = !empty($historical) ? $historical[0] : (!empty($live) ? $live[0] : null);
            if ($firstRow && is_array($firstRow)) {
                // Use the first column that's not a sum field
                foreach (array_keys($firstRow) as $key) {
                    if (!in_array($key, $sumFields)) {
                        $groupKey = $key;
                        break;
                    }
                }
            }

            // If still no group key, just merge without grouping
            if (!$groupKey) {
                return array_merge($historical, $live);
            }
        }

        $result = [];
        foreach ($historical as $row) {
            if (isset($row[$groupKey])) {
                $key          = $row[$groupKey];
                $result[$key] = $row;
            }
        }

        foreach ($live as $row) {
            if (isset($row[$groupKey])) {
                $key = $row[$groupKey];
                if (isset($result[$key])) {
                    foreach ($sumFields as $field) {
                        if (isset($row[$field])) {
                            $result[$key][$field] += $row[$field];
                        }
                    }
                } else {
                    $result[$key] = $row;
                }
            }
        }

        return array_values($result);
    }

    /**
     * Re-sort merged results to honour the original ORDER BY clause.
     *
     * After mergeGroupResults() sums counts from historical + live partitions,
     * the relative ordering may no longer match the SQL ORDER BY (e.g. a row
     * whose counthits grew after summing should move up).  This method parses
     * the stored orderClause and applies an equivalent PHP usort().
     *
     * Only fields that exist as keys in the result rows are used for sorting;
     * SQL expressions (MAX(dt), REPLACE(…), etc.) are silently skipped because
     * they don't appear as array keys after $wpdb->get_results().
     *
     * @param array $results Merged result rows (associative arrays).
     *
     * @return array Re-sorted rows.
     */
    protected function sortMergedResults(array $results): array
    {
        if (empty($this->orderClause) || empty($results)) {
            return $results;
        }

        // Strip "ORDER BY " prefix → "counthits DESC, resource ASC"
        $orderStr = preg_replace('/^ORDER\s+BY\s+/i', '', $this->orderClause);
        $parts    = array_map('trim', explode(',', $orderStr));

        // Determine which fields actually exist in the result rows.
        $availableKeys = array_keys($results[0]);

        $sortFields = [];
        foreach ($parts as $part) {
            if (preg_match('/^(.+?)\s+(ASC|DESC)$/i', $part, $m)) {
                $field = $m[1];
                $dir   = strtoupper($m[2]);

                if (in_array($field, $availableKeys, true)) {
                    $sortFields[] = ['field' => $field, 'dir' => $dir];
                }
            }
        }

        if (empty($sortFields)) {
            return $results;
        }

        usort($results, static function ($a, $b) use ($sortFields) {
            foreach ($sortFields as $sf) {
                $field = $sf['field'];
                $valA  = $a[$field] ?? null;
                $valB  = $b[$field] ?? null;

                // NULLs sort last regardless of direction.
                if (null === $valA && null === $valB) {
                    continue;
                }
                if (null === $valA) {
                    return 1;
                }
                if (null === $valB) {
                    return -1;
                }

                if (is_numeric($valA) && is_numeric($valB)) {
                    $cmp = ((float) $valA <=> (float) $valB);
                } else {
                    $cmp = strcmp((string) $valA, (string) $valB);
                }

                if (0 !== $cmp) {
                    return ('DESC' === $sf['dir']) ? -$cmp : $cmp;
                }
            }

            return 0;
        });

        return $results;
    }

    /**
     * Helper: Extract date ranges from WHERE clauses
     *
     * @return array Array of extracted date ranges with keys from, to, clauseIdx, and valueIdx
     */
    protected function extractDateRangesFromWhere()
    {
        $dtField = 'dt';
        $ranges  = [];
        $dtIdx   = 0;
        foreach ($this->whereClauses as $idx => $clause) {
            if (preg_match('/' . $dtField . ' BETWEEN %s AND %s/', $clause)) {
                $from     = $this->valuesToPrepare[$dtIdx] ?? null;
                $to       = $this->valuesToPrepare[$dtIdx + 1] ?? null;
                $ranges[] = [
                    'from'      => $from,
                    'to'        => $to,
                    'clauseIdx' => $idx,
                    'valueIdx'  => $dtIdx,
                ];
            }

            if (preg_match_all('/%s/', $clause, $m)) {
                $dtIdx += count($m[0]);
            }
        }

        return $ranges;
    }

    /**
     * Helper: Process a date range query, splitting it into historical and live parts as needed.
     *
     * @param int|string $from                Start date (Y-m-d or Y-m-d H:i:s or timestamp)
     * @param int|string $to                  End date (Y-m-d or Y-m-d H:i:s or timestamp)
     * @param array      $baseWhereClauses    where clauses to use for the query
     * @param array      $baseValuesToPrepare values to prepare for the query
     *
     * @return array result set
     */
    protected function processDateRange($from, $to, $baseWhereClauses, $baseValuesToPrepare)
    {
        $todayStart = $this->getTodayDate();
        $fromTs     = is_numeric($from) ? intval($from) : strtotime($from);
        $toTs       = is_numeric($to) ? intval($to) : strtotime($to);

        if ($fromTs >= $todayStart) {
            $liveQuery                  = clone $this;
            $liveQuery->whereClauses    = $baseWhereClauses;
            $liveQuery->valuesToPrepare = $baseValuesToPrepare;
            $liveQuery->whereDate('dt', ['from' => $fromTs, 'to' => $toTs], true);
            $liveQuery->allowCaching(false, 0);
            return $liveQuery->getAll();
        }

        if ($toTs < $todayStart) {
            $cacheQuery                  = clone $this;
            $cacheQuery->whereClauses    = $baseWhereClauses;
            $cacheQuery->valuesToPrepare = $baseValuesToPrepare;
            $cacheQuery->whereDate('dt', ['from' => $fromTs, 'to' => $toTs]);
            $cacheQuery->allowCaching(true, $this->cacheExpiration);
            return $cacheQuery->getAll();
        }

        $histQuery                  = clone $this;
        $histQuery->whereClauses    = $baseWhereClauses;
        $histQuery->valuesToPrepare = $baseValuesToPrepare;
        $histQuery->whereDate('dt', ['from' => $fromTs, 'to' => $todayStart - 1]);
        $histQuery->allowCaching(true, $this->cacheExpiration);

        $historical = $histQuery->getAll();

        $liveQuery                  = clone $this;
        $liveQuery->whereClauses    = $baseWhereClauses;
        $liveQuery->valuesToPrepare = $baseValuesToPrepare;
        $liveQuery->whereDate('dt', ['from' => $todayStart, 'to' => $toTs], true);
        $liveQuery->allowCaching(false, 0);

        $live = $liveQuery->getAll();

        if ($toTs == $todayStart) {
            return $historical;
        }

        if ($todayStart - 1 < $fromTs) {
            return $live;
        }

        return array_merge($historical, $live);
    }

    /**
     * Execute the query and return a single value from the first row
     *
     * This is a shortcut for `getAll()[0][0]`
     *
     * @return mixed The value, or false/null if no rows are returned
     */
    public function getVar()
    {
        // When caching is enabled and the date range includes today, skip cache
        // to stay consistent with getAll() which always fetches fresh live data.
        // Without this, cached scalar values (e.g. $pageviews) become stale while
        // getAll() returns fresh grouped data, causing percentage calculations >100%.
        $useCache = $this->allowCaching;
        if ($useCache) {
            [$split] = $this->getSplitDateRanges();
            if ($split) {
                $useCache = false;
            }
        }

        $query = $this->buildQuery();
        $query = $this->prepareQuery($query, $this->valuesToPrepare);
        if ($useCache) {
            $cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
            if (false !== $cachedResult) {
                return $cachedResult;
            }
        }

        $result = $this->db->get_var($query);
        if ($useCache) {
            $this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
        }

        return $result;
    }

    /**
     * Execute the query and return a single row
     *
     * This is a shortcut for `getAll()[0]`
     *
     * @return array The row, or false/null if no rows are returned
     */
    public function getRow()
    {
        $useCache = $this->allowCaching;
        if ($useCache) {
            [$split] = $this->getSplitDateRanges();
            if ($split) {
                $useCache = false;
            }
        }

        $query = $this->buildQuery();
        $query = $this->prepareQuery($query, $this->valuesToPrepare);
        if ($useCache) {
            $cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
            if (false !== $cachedResult) {
                return $cachedResult;
            }
        }

        $result = $this->db->get_row($query);
        if ($useCache) {
            $this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
        }

        return $result;
    }

    /**
     * Execute the query and return a single column
     *
     * This is a shortcut for `getAll()`
     *
     * @return array The column, or false/null if no columns are returned
     */
    public function getCol()
    {
        $useCache = $this->allowCaching;
        if ($useCache) {
            [$split] = $this->getSplitDateRanges();
            if ($split) {
                $useCache = false;
            }
        }

        $query = $this->buildQuery();
        $query = $this->prepareQuery($query, $this->valuesToPrepare);
        if ($useCache) {
            $cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
            if (false !== $cachedResult) {
                return $cachedResult;
            }
        }

        $result = $this->db->get_col($query);
        if ($useCache) {
            $this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
        }

        return $result;
    }

    /**
     * Check if a where clause for a field/operator exists (e.g. 'dt BETWEEN').
     *
     * @param string      $field
     * @param string|null $operator
     *
     * @return bool
     */
    public function hasWhereClause($field, $operator = null)
    {
        foreach ($this->whereClauses as $clause) {
            if ($operator) {
                if (false !== stripos($clause, sprintf('%s %s', $field, $operator))) {
                    return true;
                }
            } elseif (false !== stripos($clause, $field)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Add a date range condition and enable cache if possible.
     *
     * @param string       $field
     * @param array|string $date
     *
     * @return $this
     */
    public function whereDate($field, $date, $isLiveQuery = false)
    {
        if (empty($date)) {
            return $this;
        }

        if (is_array($date)) {
            $from = $date['from'] ?? '';
            $to   = $date['to'] ?? '';
        } elseif (is_string($date)) {
            $from = $date;
            $to   = $date;
        } else {
            return $this;
        }

        if ('dt' === $field) {
            if (!empty($from) && !empty($to)) {
                $fromTs = is_numeric($from) ? intval($from) : strtotime($from);
                $toTs   = is_numeric($to) ? intval($to) : strtotime($to);

                $this->whereClauses[]    = sprintf('%s BETWEEN %%s AND %%s', $field);
                $this->valuesToPrepare[] = $fromTs;
                $this->valuesToPrepare[] = $toTs;
                $this->canUseCacheForDateRange($toTs);
                if ($isLiveQuery) {
                    $this->_isLiveQuery = true;
                }
            }
        } elseif (!empty($from) && !empty($to)) {
            if (10 === strlen($from)) {
                $from .= ' 00:00:00';
            }

            if (10 === strlen($to)) {
                $to .= ' 23:59:59';
            }

            $this->whereClauses[]    = sprintf('%s BETWEEN %%s AND %%s', $field);
            $this->valuesToPrepare[] = $from;
            $this->valuesToPrepare[] = $to;
            $this->canUseCacheForDateRange($to);
            if ($isLiveQuery) {
                $this->_isLiveQuery = true;
            }
        }

        return $this;
    }

    /**
     * Executes a query for operations like INSERT, UPDATE, DELETE.
     *
     * @return int|bool Number of affected rows, or false on error. For INSERT, returns the insert ID.
     * @throws \Exception
     */
    public function execute()
    {
        if ('select' === $this->operation) {
            throw new \Exception('execute() cannot be used for SELECT queries. Use getAll(), getVar(), getRow(), or getCol().');
        }

        $query = $this->buildQuery();

        if (empty($query)) {
            return false;
        }

        // SET values must come before WHERE values to match SQL clause order
        $allValues = array_merge($this->setValuesToPrepare, $this->valuesToPrepare);
        $prepared_query = $this->prepareQuery($query, $allValues);

        $result = $this->db->query($prepared_query);

        if ('insert' === $this->operation) {
            return $this->db->insert_id ?: $result;
        }

        return $result;
    }

    public function getSqlQuery()
    {
        $query = $this->buildQuery();
        // SET values must come before WHERE values to match SQL clause order
        $allValues = array_merge($this->setValuesToPrepare, $this->valuesToPrepare);
        return $this->prepareQuery($query, $allValues);
    }

    /**
     * Get all results from a query.
     * If this is a live query (i.e. the query has a live date range), this function will
     * split the query into two parts: a historical part that can be safely cached, and a live
     * part that should not be cached.
     * If this is not a live query, the function will simply return the result of the query.
     *
     * @return array The result of the query
     */
    public function getAll()
    {
        if (null !== $this->_isLiveQuery && $this->_isLiveQuery) {
            $query = $this->buildQuery();
            $query = $this->prepareQuery($query, $this->valuesToPrepare);
            return $this->db->get_results($query, ARRAY_A);
        }

        $ranges = $this->extractDateRangesFromWhere();
        if (count($ranges) > 1) {
            $results = [];
            foreach ($ranges as $range) {
                if (empty($range['from']) || empty($range['to'])) {
                    continue;
                }

                $baseWhereClauses    = $this->whereClauses;
                $baseValuesToPrepare = $this->valuesToPrepare;
                array_splice($baseWhereClauses, $range['clauseIdx'], 1);
                array_splice($baseValuesToPrepare, $range['valueIdx'], 2);
                $data = $this->processDateRange($range['from'], $range['to'], $baseWhereClauses, $baseValuesToPrepare);
                if (is_array($data)) {
                    $results = array_merge($results, $data);
                }
            }

            return $results;
        }

        [$split, $histFrom, $histTo, $liveFrom, $liveTo, $dtIdx, $dtClauseIdx] = $this->getSplitDateRanges();
        if ($split) {
            $baseWhereClauses    = $this->whereClauses;
            $baseValuesToPrepare = $this->valuesToPrepare;
            array_splice($baseWhereClauses, $dtClauseIdx, 1);
            $baseValues = $baseValuesToPrepare;
            array_splice($baseValues, $dtIdx, 2);

            // Remove OFFSET from sub-queries: each partition is smaller
            // than the full date range, so applying the original OFFSET to
            // each one independently can skip past all rows in that
            // partition.  Instead, fetch without OFFSET and apply it after
            // merging.
            $parsedOffset = 0;
            $parsedLimit  = 0;
            if (preg_match('/LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/i', $this->limitClause, $m)) {
                $parsedLimit  = intval($m[1]);
                $parsedOffset = intval($m[2]);
            } elseif (preg_match('/LIMIT\s+(\d+)/i', $this->limitClause, $m)) {
                $parsedLimit = intval($m[1]);
            }
            // Sub-queries fetch up to offset+limit rows (no OFFSET) so we
            // have enough data to slice after merging.
            $subLimit = $parsedOffset + $parsedLimit;

            // Clone for historical
            $histQuery                  = clone $this;
            $histQuery->whereClauses    = $baseWhereClauses;
            $histQuery->valuesToPrepare = $baseValues;
            $histQuery->whereDate('dt', ['from' => $histFrom, 'to' => $histTo]);
            $histQuery->allowCaching(true, $this->cacheExpiration);
            if ($subLimit > 0) {
                $histQuery->limit($subLimit);
            }
            try {
                $historical = $histQuery->getAll();
            } catch (Exception $e) {
                $historical = [];
            }

            // Clone for live
            $liveQuery                  = clone $this;
            $liveQuery->whereClauses    = $baseWhereClauses;
            $liveQuery->valuesToPrepare = $baseValues;
            $liveQuery->whereDate('dt', ['from' => $liveFrom, 'to' => $liveTo], true);
            $liveQuery->allowCaching(false, 0);
            if ($subLimit > 0) {
                $liveQuery->limit($subLimit);
            }
            try {
                $live = $liveQuery->getAll();
            } catch (Exception $e) {
                $live = [];
            }

            if (is_array($live)) {
                $dtList = array_map(fn ($row) => $row['dt'] ?? null, $live);
            }

            // Let mergeGroupResults determine the group key automatically
            // This handles complex GROUP BY expressions and aliases properly
            $merged = $this->mergeGroupResults($live, $historical);

            // Re-sort merged results to honour the original ORDER BY.
            // mergeGroupResults() sums counthits but loses sort order.
            $merged = $this->sortMergedResults($merged);

            if (is_array($merged)) {
                $dtList = array_map(fn ($row) => $row['dt'] ?? null, $merged);
            }

            // Apply the original OFFSET+LIMIT after merging.
            // Check $parsedLimit (not $parsedOffset) so "top" reports
            // (which use LIMIT without OFFSET) also get capped after the
            // two partitions are merged and re-sorted.
            if ($parsedLimit > 0 && is_array($merged)) {
                $merged = array_slice($merged, $parsedOffset, $parsedLimit);
            }

            return $merged;
        }

        $query = $this->buildQuery();
        $query = $this->prepareQuery($query, $this->valuesToPrepare);
        if ($this->allowCaching) {
            try {
                $cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
            } catch (Exception $e) {
                $cachedResult = false;
            }

            if (false !== $cachedResult) {
                return $cachedResult;
            }
        }

        try {
            $result = $this->db->get_results($query, ARRAY_A);
        } catch (Exception $exception) {
            $result = [];
        }

        if ($this->allowCaching) {
            try {
                $this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
            } catch (Exception $exception) {
                // ignore
            }
        }

        return $result;
    }
}
