<?php
declare(strict_types=1);

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

use Cake\Database\ExpressionInterface;
use Cake\Database\ValueBinder;
use Closure;

/**
 * This represents a SQL window expression used by aggregate and window functions.
 */
class WindowExpression implements ExpressionInterface, WindowInterface
{
    /**
     * @var \Cake\Database\Expression\IdentifierExpression
     */
    protected $name;

    /**
     * @var \Cake\Database\ExpressionInterface[]
     */
    protected $partitions = [];

    /**
     * @var \Cake\Database\Expression\OrderByExpression|null
     */
    protected $order;

    /**
     * @var array|null
     */
    protected $frame;

    /**
     * @var string|null
     */
    protected $exclusion;

    /**
     * @param string $name Window name
     */
    public function __construct(string $name = '')
    {
        $this->name = new IdentifierExpression($name);
    }

    /**
     * Return whether is only a named window expression.
     *
     * These window expressions only specify a named window and do not
     * specify their own partitions, frame or order.
     *
     * @return bool
     */
    public function isNamedOnly(): bool
    {
        return $this->name->getIdentifier() && (!$this->partitions && !$this->frame && !$this->order);
    }

    /**
     * Sets the window name.
     *
     * @param string $name Window name
     * @return $this
     */
    public function name(string $name)
    {
        $this->name = new IdentifierExpression($name);

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function partition($partitions)
    {
        if (!$partitions) {
            return $this;
        }

        if ($partitions instanceof Closure) {
            $partitions = $partitions(new QueryExpression([], [], ''));
        }

        if (!is_array($partitions)) {
            $partitions = [$partitions];
        }

        foreach ($partitions as &$partition) {
            if (is_string($partition)) {
                $partition = new IdentifierExpression($partition);
            }
        }

        $this->partitions = array_merge($this->partitions, $partitions);

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function order($fields)
    {
        if (!$fields) {
            return $this;
        }

        if ($this->order === null) {
            $this->order = new OrderByExpression();
        }

        if ($fields instanceof Closure) {
            $fields = $fields(new QueryExpression([], [], ''));
        }

        $this->order->add($fields);

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function range($start, $end = 0)
    {
        return $this->frame(self::RANGE, $start, self::PRECEDING, $end, self::FOLLOWING);
    }

    /**
     * @inheritDoc
     */
    public function rows(?int $start, ?int $end = 0)
    {
        return $this->frame(self::ROWS, $start, self::PRECEDING, $end, self::FOLLOWING);
    }

    /**
     * @inheritDoc
     */
    public function groups(?int $start, ?int $end = 0)
    {
        return $this->frame(self::GROUPS, $start, self::PRECEDING, $end, self::FOLLOWING);
    }

    /**
     * @inheritDoc
     */
    public function frame(
        string $type,
        $startOffset,
        string $startDirection,
        $endOffset,
        string $endDirection
    ) {
        $this->frame = [
            'type' => $type,
            'start' => [
                'offset' => $startOffset,
                'direction' => $startDirection,
            ],
            'end' => [
                'offset' => $endOffset,
                'direction' => $endDirection,
            ],
        ];

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function excludeCurrent()
    {
        $this->exclusion = 'CURRENT ROW';

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function excludeGroup()
    {
        $this->exclusion = 'GROUP';

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function excludeTies()
    {
        $this->exclusion = 'TIES';

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function sql(ValueBinder $binder): string
    {
        $clauses = [];
        if ($this->name->getIdentifier()) {
            $clauses[] = $this->name->sql($binder);
        }

        if ($this->partitions) {
            $expressions = [];
            foreach ($this->partitions as $partition) {
                $expressions[] = $partition->sql($binder);
            }

            $clauses[] = 'PARTITION BY ' . implode(', ', $expressions);
        }

        if ($this->order) {
            $clauses[] = $this->order->sql($binder);
        }

        if ($this->frame) {
            $start = $this->buildOffsetSql(
                $binder,
                $this->frame['start']['offset'],
                $this->frame['start']['direction']
            );
            $end = $this->buildOffsetSql(
                $binder,
                $this->frame['end']['offset'],
                $this->frame['end']['direction']
            );

            $frameSql = sprintf('%s BETWEEN %s AND %s', $this->frame['type'], $start, $end);

            if ($this->exclusion !== null) {
                $frameSql .= ' EXCLUDE ' . $this->exclusion;
            }

            $clauses[] = $frameSql;
        }

        return implode(' ', $clauses);
    }

    /**
     * @inheritDoc
     */
    public function traverse(Closure $callback)
    {
        $callback($this->name);
        foreach ($this->partitions as $partition) {
            $callback($partition);
            $partition->traverse($callback);
        }

        if ($this->order) {
            $callback($this->order);
            $this->order->traverse($callback);
        }

        if ($this->frame !== null) {
            $offset = $this->frame['start']['offset'];
            if ($offset instanceof ExpressionInterface) {
                $callback($offset);
                $offset->traverse($callback);
            }
            $offset = $this->frame['end']['offset'] ?? null;
            if ($offset instanceof ExpressionInterface) {
                $callback($offset);
                $offset->traverse($callback);
            }
        }

        return $this;
    }

    /**
     * Builds frame offset sql.
     *
     * @param \Cake\Database\ValueBinder $binder Value binder
     * @param int|string|\Cake\Database\ExpressionInterface|null $offset Frame offset
     * @param string $direction Frame offset direction
     * @return string
     */
    protected function buildOffsetSql(ValueBinder $binder, $offset, string $direction): string
    {
        if ($offset === 0) {
            return 'CURRENT ROW';
        }

        if ($offset instanceof ExpressionInterface) {
            $offset = $offset->sql($binder);
        }

        $sql = sprintf(
            '%s %s',
            $offset ?? 'UNBOUNDED',
            $direction
        );

        return $sql;
    }

    /**
     * Clone this object and its subtree of expressions.
     *
     * @return void
     */
    public function __clone()
    {
        $this->name = clone $this->name;
        foreach ($this->partitions as $i => $partition) {
            $this->partitions[$i] = clone $partition;
        }
        if ($this->order !== null) {
            $this->order = clone $this->order;
        }
    }
}
