<?php

namespace Mautic\CoreBundle\Helper\Chart;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Doctrine\GeneratedColumn\GeneratedColumn;
use Mautic\CoreBundle\Doctrine\Provider\GeneratedColumnsProviderInterface;
use Mautic\CoreBundle\Helper\DateTimeHelper;

/**
 * Methods to get the chart data as native queries to get better performance and work with date/time native SQL queries.
 */
class ChartQuery extends AbstractChart
{
    private DateTimeHelper $dateTimeHelper;

    private ?GeneratedColumnsProviderInterface $generatedColumnProvider = null;

    /**
     * Match date/time unit to a SQL datetime format
     * {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}.
     *
     * @var array
     */
    protected $sqlFormats = [
        's' => 'Y-m-d H:i:s',
        'i' => 'Y-m-d H:i:00',
        'H' => 'Y-m-d H:00:00',
        'd' => 'Y-m-d 00:00:00',
        'D' => 'Y-m-d 00:00:00', // ('D' is BC. Can be removed when all charts use this class)
        'W' => 'Y-m-d 00:00:00',
        'm' => 'Y-m-01 00:00:00',
        'M' => 'Y-m-00 00:00:00', // ('M' is BC. Can be removed when all charts use this class)
        'Y' => 'Y-01-01 00:00:00',
    ];

    /**
     * Match date/time unit to a MySql datetime format
     * {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
     * {@link dev.mysql.com/doc/refman/5.5/en/date-and-time-functions.html#function_date-format}.
     *
     * @var array
     */
    protected $mysqlTimeUnits = [
        's' => '%Y-%m-%d %H:%i:%s',
        'i' => '%Y-%m-%d %H:%i',
        'H' => '%Y-%m-%d %H:00',
        'd' => '%Y-%m-%d',
        'D' => '%Y-%m-%d', // ('D' is BC. Can be removed when all charts use this class)
        'W' => '%Y %U',
        'm' => '%Y-%m',
        'M' => '%Y-%m', // ('M' is BC. Can be removed when all charts use this class)
        'Y' => '%Y',
    ];

    /**
     * Possible values are 'd'/'H'/'i'/'i'/'W'/'m'/'Y'.
     *
     * @see \Mautic\CoreBundle\Helper\Chart\DateRangeUnitTrait::getTimeUnitFromDateRange()
     *
     * @param string|null $unit
     */
    public function __construct(
        protected Connection $connection,
        \DateTime $dateFrom,
        \DateTime $dateTo,
        $unit = null,
    ) {
        $this->dateTimeHelper = new DateTimeHelper();
        $this->unit           = $unit ?? $this->getTimeUnitFromDateRange($dateFrom, $dateTo);
        $this->isTimeUnit     = in_array($this->unit, ['H', 'i', 's']);
        $this->setDateRange($dateFrom, $dateTo);
    }

    public function setGeneratedColumnProvider(GeneratedColumnsProviderInterface $generatedColumnProvider): void
    {
        $this->generatedColumnProvider = $generatedColumnProvider;
    }

    /**
     * Apply where filters to the query.
     *
     * @param QueryBuilder $query
     * @param array        $filters
     */
    public function applyFilters(&$query, $filters): void
    {
        if ($filters && is_array($filters)) {
            foreach ($filters as $column => $value) {
                $valId = $column.'_val';

                // Special case: Lead list filter
                if ('leadlist_id' === $column) {
                    $query->join('t', MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'lll', 'lll.lead_id = '.$value['list_column_name']);
                    $query->andWhere('lll.leadlist_id = :'.$valId);
                    $query->setParameter($valId, $value['value']);
                } elseif (isset($value['expression']) && method_exists($query->expr(), $value['expression'])) {
                    $query->andWhere($query->expr()->{$value['expression']}($column));
                    if (isset($value['value'])) {
                        $query->setParameter($valId, $value['value']);
                    }
                } elseif (isset($value['subquery'])) {
                    $query->andWhere($value['subquery']);
                } else {
                    $column = str_replace('t.', '', $column);
                    $valId  = str_replace('t.', '', $valId);
                    if (is_array($value)) {
                        $query->andWhere($query->expr()->in('t.'.$column, $value));
                    } else {
                        $query->andWhere('t.'.$column.' = :'.$valId);
                        $query->setParameter($valId, $value);
                    }
                }
            }
        }
    }

    /**
     * Apply date filters to the query.
     *
     * @param QueryBuilder $query
     * @param string       $dateColumn
     * @param string       $tablePrefix
     */
    public function applyDateFilters(&$query, $dateColumn, $tablePrefix = 't'): void
    {
        // Check if the date filters have already been applied
        if ($parameters = $query->getParameters()) {
            if (array_key_exists('dateTo', $parameters) || array_key_exists('dateFrom', $parameters)) {
                return;
            }
        }

        if ($dateColumn) {
            $generatedColumn = $this->getGeneratedColumnForDateColumn($query, (string) $dateColumn, (string) $tablePrefix);

            if ($generatedColumn) {
                $dateColumn = $generatedColumn->getFilterDateColumn() ?: $dateColumn;
            }

            if ($this->dateFrom && $this->dateTo) {
                // Between is faster so if we know both dates...
                /** @var \DateTime $dateFrom */
                $dateFrom = clone $this->dateFrom;
                /** @var \DateTime $dateTo */
                $dateTo   = clone $this->dateTo;
                if ($this->isTimeUnit) {
                    $dateFrom->setTimeZone(new \DateTimeZone('UTC'));
                }
                if ($this->isTimeUnit) {
                    $dateTo->setTimeZone(new \DateTimeZone('UTC'));
                }
                $query->andWhere($tablePrefix.'.'.$dateColumn.' BETWEEN :dateFrom AND :dateTo');
                $query->setParameter('dateFrom', $dateFrom->format('Y-m-d H:i:s'));
                $query->setParameter('dateTo', $dateTo->format('Y-m-d H:i:s'));
            } else {
                // Apply the start date/time if set
                if ($this->dateFrom) {
                    /** @var \DateTime $dateFrom */
                    $dateFrom = clone $this->dateFrom;
                    if ($this->isTimeUnit) {
                        $dateFrom->setTimeZone(new \DateTimeZone('UTC'));
                    }
                    $query->andWhere($tablePrefix.'.'.$dateColumn.' >= :dateFrom');
                    $query->setParameter('dateFrom', $dateFrom->format('Y-m-d H:i:s'));
                }

                // Apply the end date/time if set
                if ($this->dateTo) {
                    /** @var \DateTime $dateTo */
                    $dateTo = clone $this->dateTo;
                    if ($this->isTimeUnit) {
                        $dateTo->setTimeZone(new \DateTimeZone('UTC'));
                    }
                    $query->andWhere($tablePrefix.'.'.$dateColumn.' <= :dateTo');
                    $query->setParameter('dateTo', $dateTo->format('Y-m-d H:i:s'));
                }
            }
        }
    }

    /**
     * Get the right unit for current database platform.
     *
     * @param string $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
     *
     * @return string
     */
    public function translateTimeUnit($unit = null)
    {
        if (null === $unit) {
            $unit = $this->unit;
        }

        if (!isset($this->mysqlTimeUnits[$unit])) {
            throw new \UnexpectedValueException('Date/Time unit "'.$unit.'" is not available for MySql.');
        }

        return $this->mysqlTimeUnits[$unit];
    }

    /**
     * Prepare database query for fetching the line time chart data.
     *
     * @param string $table   without prefix
     * @param string $column  name. The column must be type of datetime
     * @param array  $filters will be added to where claues
     *
     * @return QueryBuilder
     */
    public function prepareTimeDataQuery($table, $column, $filters = [], $countColumn = '*', $isEnumerable = true, bool $useSqlOrder = true)
    {
        // Convert time unitst to the right form for current database platform
        $query = $this->connection->createQueryBuilder();
        $query->from($this->prepareTable($table), 't');

        $this->modifyTimeDataQuery($query, $column, 't', $countColumn, $isEnumerable, $useSqlOrder);
        $this->applyFilters($query, $filters);
        $this->applyDateFilters($query, $column);

        return $query;
    }

    /**
     * Modify database query for fetching the line time chart data.
     *
     * @param string      $column       name
     * @param string      $tablePrefix
     * @param string      $countColumn
     * @param bool|string $isEnumerable true = COUNT, string sum = SUM
     */
    public function modifyTimeDataQuery(QueryBuilder $query, $column, $tablePrefix = 't', $countColumn = '*', $isEnumerable = true, bool $useSqlOrder = true): void
    {
        // Convert time units to the right form for current database platform
        $limit         = $this->countAmountFromDateRange();
        $dateConstruct = $this->getDateConstruct($query, $tablePrefix, $column);

        if (true === $isEnumerable) {
            $count = 'COUNT('.$countColumn.') AS count';
        } elseif ('sum' == $isEnumerable) {
            $count = 'SUM('.$countColumn.') AS count';
        } else {
            $count = $countColumn.' AS count';
        }

        $query->select($dateConstruct.' AS date, '.$count);
        $query->groupBy($dateConstruct);
        if ($useSqlOrder) {
            // Some queries needs to avoid this because of query performance
            $query->orderBy($dateConstruct, 'ASC');
        }
        $query->setMaxResults($limit);
    }

    /**
     * Fetch data for a time related dataset.
     *
     * @param string $table   without prefix
     * @param string $column  name. The column must be type of datetime
     * @param array  $filters will be added to where claues
     */
    public function fetchTimeData($table, $column, $filters = []): array
    {
        $query = $this->prepareTimeDataQuery($table, $column, $filters);

        return $this->loadAndBuildTimeData($query);
    }

    /**
     * Fetch data and sum it for a time related dataset.
     *
     * @param string $table     without prefix
     * @param string $column    name. The column must be type of datetime
     * @param array  $filters   will be added to where claues
     * @param string $sumColumn name that will be summed
     */
    public function fetchSumTimeData($table, $column, $filters, $sumColumn): array
    {
        $query = $this->prepareTimeDataQuery($table, $column, $filters, $sumColumn, 'sum');

        return $this->loadAndBuildTimeData($query);
    }

    /**
     * Loads data from prepared query and builds the chart data.
     *
     * @param QueryBuilder $query
     */
    public function loadAndBuildTimeData($query): array
    {
        $rawData =  $query->executeQuery()->fetchAllAssociative();

        return $this->completeTimeData($rawData);
    }

    /**
     * Go through the raw data and add the missing times.
     */
    public function completeTimeData($rawData, $countAverage = false): array
    {
        $data          = [];
        $averageCounts = [];
        $oneUnit       = $this->getUnitInterval();
        $limit         = $this->countAmountFromDateRange();
        /** @var \DateTime $previousDate */
        $previousDate  = clone $this->dateFrom;
        $utcTz         = new \DateTimeZone('UTC');

        // Do not let hours to mess with date comparisions.
        $previousDate->setTime(0, 0, 0);

        if ('Y' === $this->unit) {
            $previousDate->modify('first day of January');
        } elseif ('m' == $this->unit) {
            $previousDate->modify('first day of this month');
        } elseif ('W' === $this->unit) {
            $previousDate->modify('Monday this week');
        }

        // Convert data from DB to the chart.js format
        for ($i = 0; $i < $limit; ++$i) {
            $nextDate = clone $previousDate;

            if ('m' === $this->unit) {
                $nextDate->modify('first day of next month');
            } elseif ('W' === $this->unit) {
                $nextDate->modify('Monday next week');
            } else {
                $nextDate->add($oneUnit);
            }

            foreach ($rawData as $key => &$item) {
                if (!isset($item['date_comparison'])) {
                    if (!$item['date'] instanceof \DateTime) {
                        /*
                         * PHP DateTime cannot parse the Y W (ex 2016 09)
                         * format, so we transform it into d-M-Y.
                         */
                        if ('W' === $this->unit && isset($item['date'])) {
                            [$year, $week]     = explode(' ', $item['date']);
                            $newDate           = new \DateTime();
                            $newDate->setISODate($year, $week);
                            $item['date'] = $newDate->format('d-M-Y');
                            unset($newDate);
                        }

                        // Time based data from the database will always in UTC; otherwise assume local
                        // since changing the timezone could result in wrong placement
                        $itemDate = new \DateTime($item['date'], ($this->isTimeUnit) ? $utcTz : $this->timezone);
                    } else {
                        $itemDate = clone $item['date'];
                    }

                    if (!$this->isTimeUnit) {
                        // Hours do not matter so let's reset to 00:00:00 for date comparison
                        $itemDate->setTime(0, 0, 0);
                    } else {
                        // Convert to the timezone used for comparison
                        $itemDate->setTimezone($this->timezone);
                    }

                    $item['date_comparison'] = $itemDate;
                } else {
                    $itemDate = $item['date_comparison'];
                }

                // Place the right suma is between the time unit and time unit +1
                if (isset($item['count']) && $itemDate >= $previousDate && $itemDate < $nextDate) {
                    $data[$i] = $item['count'];
                    unset($rawData[$key]);
                    continue;
                }

                // Add the right item is between the time unit and time unit +1
                if (isset($item['data']) && $itemDate >= $previousDate && $itemDate < $nextDate) {
                    if (isset($data[$i])) {
                        $data[$i] += $item['data'];
                        if ($countAverage) {
                            ++$averageCounts[$i];
                        }
                    } else {
                        $data[$i] = $item['data'];
                        if ($countAverage) {
                            $averageCounts[$i] = 1;
                        }
                    }
                    unset($rawData[$key]);
                    continue;
                }
            }

            // Chart.js requires the 0 for empty data, but the array slot has to exist
            if (!isset($data[$i])) {
                $data[$i] = 0;
                if ($countAverage) {
                    $averageCounts[$i] = 0;
                }
            }

            $previousDate = $nextDate;
        }

        if ($countAverage) {
            foreach ($data as $key => $value) {
                if (!empty($averageCounts[$key])) {
                    $data[$key] = round($data[$key] / $averageCounts[$key], 2);
                }
            }
        }

        return $data;
    }

    /**
     * Count occurences of a value in a column.
     *
     * @param string $table        without prefix
     * @param string $uniqueColumn name
     * @param string $dateColumn   name
     * @param array  $filters      will be added to where claues
     * @param array  $options      for special behavior
     *
     * @return QueryBuilder $query
     */
    public function getCountQuery($table, $uniqueColumn, $dateColumn = null, $filters = [], $options = [], $tablePrefix = 't')
    {
        $query = $this->connection->createQueryBuilder();
        $query->from($this->prepareTable($table), $tablePrefix);
        $this->modifyCountQuery($query, $uniqueColumn, $dateColumn, $tablePrefix);
        $this->applyFilters($query, $filters);
        $this->applyDateFilters($query, $dateColumn);

        return $query;
    }

    /**
     * Modify the query to count occurences of a value in a column.
     *
     * @param string $uniqueColumn name
     * @param array  $options      for special behavior
     * @param string $tablePrefix
     */
    public function modifyCountQuery(QueryBuilder &$query, $uniqueColumn, $options = [], $tablePrefix = 't')
    {
        $query->select('COUNT('.$tablePrefix.'.'.$uniqueColumn.') AS count');

        // Count only unique values
        if (!empty($options['getUnique'])) {
            $selectAlso = '';
            if (isset($options['selectAlso'])) {
                $selectAlso = ', '.implode(', ', $options['selectAlso']);
            }
            // Modify the previous query
            $query->select($tablePrefix.'.'.$uniqueColumn.$selectAlso);
            $query->having('COUNT(*) = 1')
                ->groupBy($tablePrefix.'.'.$uniqueColumn.$selectAlso);

            // Create a new query with subquery of the previous query
            $uniqueQuery = $this->connection->createQueryBuilder();
            $uniqueQuery->select('COUNT('.$tablePrefix.'.'.$uniqueColumn.') AS count')
                ->from('('.$query->getSql().')', $tablePrefix);

            // Apply params from the previous query to the new query
            $uniqueQuery->setParameters($query->getParameters());

            // Replace the new query with previous query
            $query = $uniqueQuery;
        }

        return $query;
    }

    /**
     * Count occurences of a value in a column.
     *
     * @param string $table        without prefix
     * @param string $uniqueColumn name
     * @param string $dateColumn   name
     * @param array  $filters      will be added to where claues
     * @param array  $options      for special behavior
     */
    public function count($table, $uniqueColumn, $dateColumn = null, $filters = [], $options = []): int
    {
        $query = $this->getCountQuery($table, $uniqueColumn, $dateColumn, $filters);

        return $this->fetchCount($query);
    }

    /**
     * Fetch the count integet from a query.
     */
    public function fetchCount(QueryBuilder $query): int
    {
        $data = $query->executeQuery()->fetchAssociative();

        return (int) $data['count'];
    }

    /**
     * Get the query to count how many rows is between a range of date diff in seconds.
     *
     * @param string $table       without prefix
     * @param string $dateColumn1
     * @param string $dateColumn2
     * @param int    $startSecond
     * @param int    $endSecond
     * @param array  $filters     will be added to where claues
     * @param string $tablePrefix
     *
     * @return QueryBuilder $query
     */
    public function getCountDateDiffQuery($table, $dateColumn1, $dateColumn2, $startSecond = 0, $endSecond = 60, $filters = [], $tablePrefix = 't')
    {
        $query = $this->connection->createQueryBuilder();
        $query->from($this->prepareTable($table), $tablePrefix);
        $this->modifyCountDateDiffQuery($query, $dateColumn1, $dateColumn2, $startSecond, $endSecond, $tablePrefix);
        $this->applyFilters($query, $filters);
        $this->applyDateFilters($query, $dateColumn1);

        return $query;
    }

    /**
     * Modify the query to count how many rows is between a range of date diff in seconds.
     *
     * @param string $dateColumn1
     * @param string $dateColumn2
     * @param int    $startSecond
     * @param int    $endSecond
     * @param string $tablePrefix
     */
    public function modifyCountDateDiffQuery(QueryBuilder &$query, $dateColumn1, $dateColumn2, $startSecond = 0, $endSecond = 60, $tablePrefix = 't'): void
    {
        $query->select('COUNT('.$tablePrefix.'.'.$dateColumn1.') AS count');
        $query->where('TIMESTAMPDIFF(SECOND, '.$tablePrefix.'.'.$dateColumn1.', '.$tablePrefix.'.'.$dateColumn2.') >= :startSecond');
        $query->andWhere('TIMESTAMPDIFF(SECOND, '.$tablePrefix.'.'.$dateColumn1.', '.$tablePrefix.'.'.$dateColumn2.') < :endSecond');

        $query->setParameter('startSecond', $startSecond);
        $query->setParameter('endSecond', $endSecond);
    }

    /**
     * Count how many rows is between a range of date diff in seconds.
     *
     * @param QueryBuilder $query
     */
    public function fetchCountDateDiff($query): int
    {
        $data = $query->executeQuery()->fetchAssociative();

        return (int) $data['count'];
    }

    /**
     * @return mixed
     */
    protected function prepareTable($table)
    {
        if (MAUTIC_TABLE_PREFIX && str_starts_with($table, MAUTIC_TABLE_PREFIX)) {
            return $table;
        }

        if (str_starts_with($table, '(')) {
            return $table;
        }

        return MAUTIC_TABLE_PREFIX.$table;
    }

    /**
     * @param string $tablePrefix
     * @param string $column
     */
    private function getDateConstruct(QueryBuilder $query, $tablePrefix, $column): string
    {
        $generatedColumn = $this->getGeneratedColumnForDateColumn($query, (string) $column, (string) $tablePrefix);

        if ($generatedColumn) {
            return $tablePrefix.'.'.$generatedColumn->getColumnName();
        }

        $dbUnit                = $this->translateTimeUnit($this->unit);
        $columnName            = $tablePrefix.'.'.$column;
        $defaultTimezoneOffset = $this->dateTimeHelper->getLocalTimezoneOffset();
        $columnName            = "CONVERT_TZ($columnName, '+00:00', '{$defaultTimezoneOffset}')";

        return 'DATE_FORMAT('.$columnName.', \''.$dbUnit.'\')';
    }

    private function getGeneratedColumnForDateColumn(QueryBuilder $query, string $dateColumn, string $tablePrefix): ?GeneratedColumn
    {
        if (!$this->generatedColumnProvider) {
            return null;
        }

        $tableName = $this->getTableNameByAlias($query, $tablePrefix);

        try {
            return $this->generatedColumnProvider->getGeneratedColumns()
                ->getGeneratedColumnForDateColumn($tableName, $dateColumn, $this->unit);
        } catch (\UnexpectedValueException) {
            return null;
        }
    }

    private function getTableNameByAlias(QueryBuilder $query, string $alias): string
    {
        foreach ($query->getQueryPart('from') as $from) {
            $fromAlias = $from['alias'] ?? null;
            $fromTable = $from['table'] ?? null;

            if ($alias === $fromAlias && null !== $fromTable) {
                return $fromTable;
            }
        }

        foreach ($query->getQueryPart('join') as $joins) {
            foreach ($joins as $join) {
                $joinAlias = $join['joinAlias'] ?? null;
                $joinTable = $join['joinTable'] ?? null;

                if ($alias === $joinAlias && null !== $joinTable) {
                    return $joinTable;
                }
            }
        }

        throw new \LogicException(sprintf('Cannot find a table name for the alias "%s".', $alias));
    }
}
