<?php

namespace Mautic\ReportBundle\Builder;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\ChannelBundle\Helper\ChannelListHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\ReportBundle\Entity\Report;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Mautic\ReportBundle\ReportEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

final class MauticReportBuilder implements ReportBuilderInterface
{
    /**
     * @var array
     */
    public const OPERATORS = [
        'default' => [
            'eq'         => 'mautic.core.operator.equals',
            'gt'         => 'mautic.core.operator.greaterthan',
            'gte'        => 'mautic.core.operator.greaterthanequals',
            'lt'         => 'mautic.core.operator.lessthan',
            'lte'        => 'mautic.core.operator.lessthanequals',
            'neq'        => 'mautic.core.operator.notequals',
            'like'       => 'mautic.core.operator.islike',
            'notLike'    => 'mautic.core.operator.isnotlike',
            'empty'      => 'mautic.core.operator.isempty',
            'notEmpty'   => 'mautic.core.operator.isnotempty',
            'contains'   => 'mautic.core.operator.contains',
            'startsWith' => 'mautic.core.operator.starts.with',
            'endsWith'   => 'mautic.core.operator.ends.with',
        ],
        'bool' => [
            'eq'  => 'mautic.core.operator.equals',
            'neq' => 'mautic.core.operator.notequals',
        ],
        'int' => [
            'eq'  => 'mautic.core.operator.equals',
            'gt'  => 'mautic.core.operator.greaterthan',
            'gte' => 'mautic.core.operator.greaterthanequals',
            'lt'  => 'mautic.core.operator.lessthan',
            'lte' => 'mautic.core.operator.lessthanequals',
            'neq' => 'mautic.core.operator.notequals',
        ],
        'multiselect' => [
            'in'    => 'mautic.core.operator.in',
            'notIn' => 'mautic.core.operator.notin',
        ],
        'select' => [
            'eq'  => 'mautic.core.operator.equals',
            'neq' => 'mautic.core.operator.notequals',
        ],
        'text' => [
            'eq'         => 'mautic.core.operator.equals',
            'neq'        => 'mautic.core.operator.notequals',
            'empty'      => 'mautic.core.operator.isempty',
            'notEmpty'   => 'mautic.core.operator.isnotempty',
            'like'       => 'mautic.core.operator.islike',
            'notLike'    => 'mautic.core.operator.isnotlike',
            'contains'   => 'mautic.core.operator.contains',
            'startsWith' => 'mautic.core.operator.starts.with',
            'endsWith'   => 'mautic.core.operator.ends.with',
        ],
    ];

    /**
     * Standard Channel Columns.
     */
    public const CHANNEL_COLUMN_CATEGORY_ID     = 'channel.category_id';

    public const CHANNEL_COLUMN_NAME            = 'channel.name';

    public const CHANNEL_COLUMN_DESCRIPTION     = 'channel.description';

    public const CHANNEL_COLUMN_DATE_ADDED      = 'channel.date_added';

    public const CHANNEL_COLUMN_CREATED_BY      = 'channel.created_by';

    public const CHANNEL_COLUMN_CREATED_BY_USER = 'channel.created_by_user';

    private ?string $contentTemplate = null;

    public function __construct(
        private EventDispatcherInterface $dispatcher,
        private Connection $db,
        private Report $entity,
        private ChannelListHelper $channelListHelper,
    ) {
    }

    /**
     * @return QueryBuilder
     *
     * @throws InvalidReportQueryException
     */
    public function getQuery(array $options)
    {
        $queryBuilder = $this->configureBuilder($options);

        if (!array_key_exists('select', $queryBuilder->getQueryParts())) {
            throw new InvalidReportQueryException('Only SELECT statements are valid');
        }

        return $queryBuilder;
    }

    /**
     * Gets the getContentTemplate path.
     */
    public function getContentTemplate(): ?string
    {
        return $this->contentTemplate;
    }

    /**
     * This method configures the ReportBuilder. It has to return a configured Doctrine DBAL QueryBuilder.
     *
     * @param array $options Options array
     *
     * @return QueryBuilder
     */
    private function configureBuilder(array $options)
    {
        $event = new ReportGeneratorEvent($this->entity, $options, $this->db->createQueryBuilder(), $this->channelListHelper);

        // Trigger the REPORT_ON_GENERATE event to initialize the QueryBuilder
        $this->dispatcher->dispatch($event, ReportEvents::REPORT_ON_GENERATE);

        // Build the QUERY
        $queryBuilder = $event->getQueryBuilder();

        // Set Content Template
        $this->contentTemplate = $event->getContentTemplate();
        $standardFilters       = $this->entity->getFilters();

        // Setup filters
        if (isset($options['dynamicFilters'])) {
            $dynamicFilters = $options['dynamicFilters'];

            foreach ($dynamicFilters as $key => $dynamicFilter) {
                foreach ($standardFilters as $i => $filter) {
                    if ($filter['column'] === $key && $filter['dynamic']) {
                        $value     = $dynamicFilter['value'];
                        $condition = $filter['condition'];

                        switch ($condition) {
                            case 'startsWith':
                                $value = $value.'%';
                                break;
                            case 'endsWith':
                                $value = '%'.$value;
                                break;
                            case 'like':
                            case 'notLike':
                            case 'contains':
                                if ('notLike' === $condition) {
                                    $dynamicFilter['expr'] = 'notLike';
                                }

                                $value = '%'.$value.'%';
                                break;
                        }

                        $dynamicFilter['value'] = $value;

                        // Overwrite the standard filter with the dynamic
                        $standardFilters[$i] = array_merge($filter, $dynamicFilter);
                    }
                }
            }
        }

        // Build WHERE clause
        if (!empty($standardFilters)) {
            if (!$filterExpr = $event->getFilterExpression()) {
                $this->applyFilters($standardFilters, $queryBuilder, $options['filters']);
            } else {
                $queryBuilder->andWhere($filterExpr);
            }
        }

        // Build ORDER BY clause
        if (!empty($options['order'])) {
            if (is_array($options['order'])) {
                if (isset($options['order']['column'])) {
                    $queryBuilder->orderBy($options['order']['column'], $options['order']['direction']);
                } elseif (!empty($options['order'][0][1])) {
                    [$column, $dir] = $options['order'];
                    $queryBuilder->orderBy($column, $dir);
                } else {
                    foreach ($options['order'] as $order) {
                        $queryBuilder->orderBy($order);
                    }
                }
            } else {
                $queryBuilder->orderBy($options['order']);
            }
        } elseif ($order = $this->entity->getTableOrder()) {
            foreach ($order as $o) {
                if (!empty($options['columns'][$o['column']]['formula'])) {
                    $queryBuilder->orderBy($options['columns'][$o['column']]['formula'], $o['direction']);
                } elseif (!empty($o['column'])) {
                    $queryBuilder->orderBy($o['column'], $o['direction']);
                }
            }
        }

        // Build GROUP BY
        if ($groupByOptions = $this->entity->getGroupBy()) {
            $groupByColumns = [];

            foreach ($groupByOptions as $groupBy) {
                if (isset($options['columns'][$groupBy])) {
                    $fieldOptions = $options['columns'][$groupBy];

                    if (isset($fieldOptions['groupByFormula'])) {
                        $groupByColumns[] = $fieldOptions['groupByFormula'];
                    } elseif (isset($fieldOptions['formula'])) {
                        $groupByColumns[] = $fieldOptions['formula'];
                    } else {
                        $groupByColumns[] = $groupBy;
                    }
                }
            }

            $queryBuilder->addGroupBy($groupByColumns);
        } elseif (!empty($options['groupby']) && empty($groupByOptions)) {
            $queryBuilder->addGroupBy($options['groupby']);
        }

        // Build LIMIT clause
        if (!empty($options['limit'])) {
            $queryBuilder->setFirstResult($options['start'])
                ->setMaxResults($options['limit']);
        }

        if (!empty($options['having'])) {
            if (is_array($options['having'])) {
                foreach ($options['having'] as $having) {
                    $queryBuilder->andHaving($having);
                }
            } else {
                $queryBuilder->having($options['having']);
            }
        }

        $selectColumns = [];

        // Build SELECT clause
        if (!$event->getSelectColumns()) {
            $fields             = $this->entity->getColumns();
            $groupByColumns     = $queryBuilder->getQueryPart('groupBy');
            $groupByColumnsKeys = array_flip($groupByColumns);

            foreach ($fields as $field) {
                if (isset($options['columns'][$field])) {
                    $fieldOptions = $options['columns'][$field];

                    if (array_key_exists('channelData', $fieldOptions)) {
                        $selectText = $this->buildCaseSelect($fieldOptions['channelData']);
                    } else {
                        // If there is a group by, and this field has groupByFormula
                        if (isset($fieldOptions['groupByFormula']) && isset($groupByColumnsKeys[$fieldOptions['groupByFormula']])) {
                            $selectText = $fieldOptions['groupByFormula'];
                        } elseif (isset($fieldOptions['formula'])) {
                            $selectText = $fieldOptions['formula'];
                        } else {
                            $selectText = $this->sanitizeColumnName($field);
                        }
                    }

                    // support for prefix and suffix to value in query
                    $prefix     = $fieldOptions['prefix'] ?? '';
                    $suffix     = $fieldOptions['suffix'] ?? '';
                    if ($prefix || $suffix) {
                        $selectText = 'CONCAT(\''.$prefix.'\', '.$selectText.',\''.$suffix.'\')';
                    }

                    if (isset($fieldOptions['alias'])) {
                        $selectText .= ' AS '.$fieldOptions['alias'];
                    }

                    $selectColumns[] = $selectText;
                }
            }
        }

        // Generate a count query in case a formula needs total number
        $countQuery = clone $queryBuilder;
        $countQuery->select('COUNT(*) as count');

        $countSql = sprintf('(%s)', $countQuery->getSQL());

        // Replace {{count}} with the count query
        array_walk($selectColumns, function (&$columnValue, $columnIndex) use ($countSql): void {
            if (str_contains($columnValue, '{{count}}')) {
                $columnValue = str_replace('{{count}}', $countSql, $columnValue);
            }
        });

        $queryBuilder->addSelect($selectColumns);

        // Add Aggregators
        $aggregators      = $this->entity->getAggregators();
        $aggregatorSelect = [];

        if ($aggregators && $groupByOptions) {
            foreach ($aggregators as $aggregator) {
                if (isset($options['columns'][$aggregator['column']]) && isset($options['columns'][$aggregator['column']]['formula'])) {
                    $columnSelect = $options['columns'][$aggregator['column']]['formula'];
                } else {
                    $columnSelect = $aggregator['column'];
                }

                $selectText = sprintf('%s(%s)', $aggregator['function'], $columnSelect);

                $aggregatorSelect[] = sprintf("%s AS '%s %s'", $selectText, $aggregator['function'], $aggregator['column']);
            }

            $queryBuilder->addSelect($aggregatorSelect);
        }

        return $queryBuilder;
    }

    /**
     * Build a CASE select statement.
     *
     * @param array $channelData ['channelName' => ['prefix' => XX, 'column' => 'XX.XX']
     */
    private function buildCaseSelect(array $channelData): string
    {
        $case = 'CASE';

        foreach ($channelData as $data) {
            $case .= ' WHEN '.$data['column'].' IS NOT NULL THEN '.$data['column'];
        }

        return $case.' ELSE NULL END ';
    }

    private function applyFilters(array $filters, QueryBuilder $queryBuilder, array $filterDefinitions): void
    {
        $expr     = $queryBuilder->expr();
        $orGroups = [];
        $andGroup = [];

        if (count($filters)) {
            foreach ($filters as $i => $filter) {
                $exprFunction = $filter['expr'] ?? $filter['condition'];
                $paramName    = sprintf('i%dc%s', $i, InputHelper::alphanum($filter['column']));

                if (array_key_exists('glue', $filter) && 'or' === $filter['glue']) {
                    if ($andGroup) {
                        $orGroups[] = CompositeExpression::and(...$andGroup);
                        $andGroup   = [];
                    }
                }

                $tagCondition = $this->getTagCondition($filter);
                if ($tagCondition) {
                    $andGroup[] = $tagCondition;
                    continue;
                }

                $dncCondition = $this->getDncCondition($filter);
                if ($dncCondition) {
                    $andGroup[] = $dncCondition;
                    continue;
                }

                switch ($exprFunction) {
                    case 'notEmpty':
                        $andGroup[] = $expr->isNotNull($filter['column']);
                        if ($this->doesColumnSupportEmptyValue($filter, $filterDefinitions)) {
                            $andGroup[] = $expr->neq($filter['column'], $expr->literal(''));
                        }
                        break;
                    case 'empty':
                        $expression = $queryBuilder->expr()->or(
                            $queryBuilder->expr()->isNull($filter['column'])
                        );
                        if ($this->doesColumnSupportEmptyValue($filter, $filterDefinitions)) {
                            $expression = $expression->with(
                                $queryBuilder->expr()->eq($filter['column'], $expr->literal(''))
                            );
                        }

                        $andGroup[] = $expression;
                        break;
                    case 'neq':
                        $columnValue = ":$paramName";
                        $expression  = $queryBuilder->expr()->or(
                            $queryBuilder->expr()->isNull($filter['column']),
                            $queryBuilder->expr()->$exprFunction($filter['column'], $columnValue)
                        );
                        $queryBuilder->setParameter($paramName, $filter['value']);
                        $andGroup[] = $expression;
                        break;
                    default:
                        if ('' == trim($filter['value'])) {
                            // Ignore empty
                            break;
                        }

                        $columnValue = ":$paramName";
                        $type        = $filterDefinitions[$filter['column']]['type'];
                        if (isset($filterDefinitions[$filter['column']]['formula'])) {
                            $filter['column'] = $filterDefinitions[$filter['column']]['formula'];
                        }

                        switch ($type) {
                            case 'bool':
                            case 'boolean':
                                if ((int) $filter['value'] > 1) {
                                    // Ignore the "reset" value of "2"
                                    break 2;
                                }

                                $queryBuilder->setParameter($paramName, $filter['value'], 'boolean');
                                break;

                            case 'float':
                                $columnValue = (float) $filter['value'];
                                break;

                            case 'int':
                            case 'integer':
                                $columnValue = (int) $filter['value'];
                                break;

                            case 'text':
                            case 'string':
                            case 'email':
                            case 'url':
                                switch ($exprFunction) {
                                    case 'like':
                                    case 'notLike':
                                        $filter['value'] = !str_contains($filter['value'], '%') ? '%'.$filter['value'].'%' : $filter['value'];
                                        break;
                                    case 'startsWith':
                                        $exprFunction    = 'like';
                                        $filter['value'] = $filter['value'].'%';
                                        break;
                                    case 'endsWith':
                                        $exprFunction    = 'like';
                                        $filter['value'] = '%'.$filter['value'];
                                        break;
                                    case 'contains':
                                        $exprFunction    = 'like';
                                        $filter['value'] = '%'.$filter['value'].'%';
                                        break;
                                }

                                $queryBuilder->setParameter($paramName, $filter['value']);
                                break;

                            default:
                                $queryBuilder->setParameter($paramName, $filter['value']);
                        }
                        $andGroup[] = $expr->{$exprFunction}($filter['column'], $columnValue);
                }
            }
        }

        if ($orGroups) {
            // Add the remaining $andGroup to the rest of the $orGroups if exists so we don't miss it.
            $orGroups[] = CompositeExpression::and(...$andGroup);
            $queryBuilder->andWhere(CompositeExpression::or(...$orGroups));
        } elseif ($andGroup) {
            $queryBuilder->andWhere(CompositeExpression::and(...$andGroup));
        }
    }

    /**
     * @param array<string, mixed> $filter
     */
    public function getTagCondition(array $filter): ?string
    {
        if ('tag' !== $filter['column']) {
            return null;
        }

        $tagSubQuery = $this->db->createQueryBuilder();
        $tagSubQuery->select('DISTINCT lead_id')
            ->from(MAUTIC_TABLE_PREFIX.'lead_tags_xref', 'ltx');

        if (in_array($filter['condition'], ['in', 'notIn']) && !empty($filter['value'])) {
            $tagSubQuery->where($tagSubQuery->expr()->in('ltx.tag_id', $filter['value']));
        }

        if (in_array($filter['condition'], ['in', 'notEmpty'])) {
            return $tagSubQuery->expr()->in('l.id', $tagSubQuery->getSQL());
        } elseif (in_array($filter['condition'], ['notIn', 'empty'])) {
            return $tagSubQuery->expr()->notIn('l.id', $tagSubQuery->getSQL());
        }

        return null;
    }

    /**
     * Get the Do Not Contact (DNC) condition for a query based on the provided filter.
     *
     * @param array{
     *     column: string,
     *     condition: string,
     *     value: string[]
     * } $filter The filter array containing 'column', 'condition', and 'value' keys
     */
    public function getDncCondition(array $filter): ?string
    {
        if ('dnc_preferences' !== $filter['column']) {
            return null;
        }

        // Handle empty/notEmpty conditions early to avoid unnecessary processing
        if (in_array($filter['condition'], ['empty', 'notEmpty'], true)) {
            $operator = 'empty' === $filter['condition'] ? 'NOT IN' : 'IN';

            return sprintf(
                'l.id %s (SELECT DISTINCT lead_id FROM %slead_donotcontact)',
                $operator,
                MAUTIC_TABLE_PREFIX
            );
        }

        // Parse and validate filter values
        $conditions = array_map(
            function (string $item) {
                $parts = explode(':', $item);
                if (2 !== count($parts)) {
                    throw new \InvalidArgumentException('Invalid DNC filter format');
                }

                return [
                    'channel' => $this->db->quote($parts[0]),
                    'reason'  => (int) $parts[1],
                ];
            },
            $filter['value']
        );

        if (empty($conditions)) {
            return null;
        }

        // Build the subquery
        $dncSubQuery = $this->db->createQueryBuilder()
            ->select('DISTINCT lead_id')
            ->from(MAUTIC_TABLE_PREFIX.'lead_donotcontact', 'ldnc')
            ->where(implode(' OR ', array_map(
                fn ($condition) => sprintf(
                    '(ldnc.channel = %s AND ldnc.reason = %d)',
                    $condition['channel'],
                    $condition['reason']
                ),
                $conditions
            )));

        // Generate final condition
        $operator = 'in' === $filter['condition'] ? 'IN' : 'NOT IN';

        return sprintf('l.id %s (%s)', $operator, $dncSubQuery->getSQL());
    }

    /**
     * We must sanitize the table aliases as they might be auto generated.
     * Aliases like "8e296a06" makes MySql to think it is a number.
     * Expects param in format "table_alias.column_name".
     */
    private function sanitizeColumnName(string $fullColumnName): string
    {
        [$tableAlias, $columnName] = explode('.', $fullColumnName);

        return "`{$tableAlias}`.`{$columnName}`";
    }

    /**
     * @param mixed[] $filter
     * @param mixed[] $filterDefinitions
     */
    private function doesColumnSupportEmptyValue(array $filter, array $filterDefinitions): bool
    {
        $type = $filterDefinitions[$filter['column']]['type'] ?? null;

        return !in_array($type, ['date', 'datetime'], true);
    }
}
