<?php

declare(strict_types=1);

namespace Atk4\Data\Model\Scope;

use Atk4\Core\ReadableCaptionTrait;
use Atk4\Data\Exception;
use Atk4\Data\Field;
use Atk4\Data\Model;
use Atk4\Data\Persistence;
use Atk4\Data\Persistence\Sql\Expression;
use Atk4\Data\Persistence\Sql\Expressionable;
use Atk4\Data\Persistence\Sql\Sqlite\Expression as SqliteExpression;

class Condition extends AbstractScope
{
    use ReadableCaptionTrait;

    /** @var string|Field|Expressionable */
    public $field;

    public ?string $operator = null;

    /** @var mixed */
    public $value;

    public const OPERATOR_EQUALS = '=';
    public const OPERATOR_DOESNOT_EQUAL = '!=';
    public const OPERATOR_GREATER = '>';
    public const OPERATOR_GREATER_EQUAL = '>=';
    public const OPERATOR_LESS = '<';
    public const OPERATOR_LESS_EQUAL = '<=';
    public const OPERATOR_IN = 'IN';
    public const OPERATOR_NOT_IN = 'NOT IN';
    public const OPERATOR_LIKE = 'LIKE';
    public const OPERATOR_NOT_LIKE = 'NOT LIKE';
    public const OPERATOR_REGEXP = 'REGEXP';
    public const OPERATOR_NOT_REGEXP = 'NOT REGEXP';

    /** @var array<string, array<string, string>> */
    protected static array $operators = [
        self::OPERATOR_EQUALS => [
            'negate' => self::OPERATOR_DOESNOT_EQUAL,
            'label' => 'is equal to',
        ],
        self::OPERATOR_DOESNOT_EQUAL => [
            'negate' => self::OPERATOR_EQUALS,
            'label' => 'is not equal to',
        ],
        self::OPERATOR_LESS => [
            'negate' => self::OPERATOR_GREATER_EQUAL,
            'label' => 'is smaller than',
        ],
        self::OPERATOR_GREATER => [
            'negate' => self::OPERATOR_LESS_EQUAL,
            'label' => 'is greater than',
        ],
        self::OPERATOR_GREATER_EQUAL => [
            'negate' => self::OPERATOR_LESS,
            'label' => 'is greater or equal to',
        ],
        self::OPERATOR_LESS_EQUAL => [
            'negate' => self::OPERATOR_GREATER,
            'label' => 'is smaller or equal to',
        ],
        self::OPERATOR_IN => [
            'negate' => self::OPERATOR_NOT_IN,
            'label' => 'is one of',
        ],
        self::OPERATOR_NOT_IN => [
            'negate' => self::OPERATOR_IN,
            'label' => 'is not one of',
        ],
        self::OPERATOR_LIKE => [
            'negate' => self::OPERATOR_NOT_LIKE,
            'label' => 'is like',
        ],
        self::OPERATOR_NOT_LIKE => [
            'negate' => self::OPERATOR_LIKE,
            'label' => 'is not like',
        ],
        self::OPERATOR_REGEXP => [
            'negate' => self::OPERATOR_NOT_REGEXP,
            'label' => 'matches regex',
        ],
        self::OPERATOR_NOT_REGEXP => [
            'negate' => self::OPERATOR_REGEXP,
            'label' => 'does not match regex',
        ],
    ];

    /**
     * @param string|Expressionable                 $field
     * @param ($value is null ? mixed : string)     $operator
     * @param ($operator is string ? mixed : never) $value
     */
    public function __construct($field, $operator = null, $value = null)
    {
        if ($field instanceof AbstractScope) {
            throw new Exception('Only Scope can contain another conditions');
        } elseif ($field instanceof Field) { // for BC
            $field = $field->shortName;
        } elseif (!is_string($field) && !$field instanceof Expressionable) { // @phpstan-ignore instanceof.alwaysTrue, booleanAnd.alwaysFalse
            throw new Exception('Field must be a string or an instance of Expressionable');
        }

        if ('func_num_args'() === 2) {
            $value = $operator;
            $operator = self::OPERATOR_EQUALS;
        }

        $this->field = $field;
        $this->value = $value;

        if ($operator === null) {
            // at least MSSQL database always requires an operator
            if (!$field instanceof Expressionable || $value !== null) {
                throw new Exception('Operator must be specified');
            }
        } else {
            $this->operator = strtoupper($operator);

            if (!array_key_exists($this->operator, self::$operators)) {
                throw (new Exception('Operator is not supported'))
                    ->addMoreInfo('operator', $operator);
            }
        }
    }

    #[\Override]
    protected function onChangeModel(): void
    {
        $model = $this->getModel();
        if ($model === null) {
            return;
        }

        if (is_array($this->value)) {
            // field containing '/' means chained references and it is handled in toQueryArguments method
            $field = $this->field;
            if (is_string($field) && !str_contains($field, '/')) {
                $field = $model->getField($field);
            }

            if ($field instanceof Field && in_array($field->type, ['string', 'text', 'boolean', 'smallint', 'integer', 'bigint', 'float', 'decimal', 'atk4_money'], true)) { // common scalar DBAL types
                foreach ($this->value as $v) {
                    if (is_array($v)) {
                        throw (new Exception('Multi-dimensional array as condition value is not supported'))
                            ->addMoreInfo('value', $this->value);
                    }
                }

                if (!in_array($this->operator, [self::OPERATOR_IN, self::OPERATOR_NOT_IN], true)) {
                    throw (new Exception('Operator is not supported for array condition value'))
                        ->addMoreInfo('operator', $this->operator)
                        ->addMoreInfo('value', $this->value);
                }
            }
        }

        // if we have a definitive equal condition set the value as default value for field
        // new records will automatically get this value assigned for the field
        // TODO: fix when condition is part of OR scope
        if ($this->operator === self::OPERATOR_EQUALS
            && !$this->value instanceof Expressionable
            && !$this->value instanceof Persistence\Array_\Action // needed to pass hintable tests
        ) {
            // field containing '/' means chained references and it is handled in toQueryArguments method
            $field = $this->field;
            if (is_string($field) && !str_contains($field, '/')) {
                $field = $model->getField($field);
            }

            // TODO Model/field should not be mutated, see:
            // https://github.com/atk4/data/issues/662
            // for now, do not set default at least for PK/ID
            if ($field instanceof Field && $field->shortName !== $field->getOwner()->idField) {
                $field->system = true;
                $fakePersistence = new Persistence\Array_();
                $valueCloned = $fakePersistence->typecastLoadField($field, $fakePersistence->typecastSaveField($field, $this->value));
                $field->default = $valueCloned;
            }
        }
    }

    /**
     * @return array{mixed}|array{mixed, string|null, mixed}
     */
    public function toQueryArguments(): array
    {
        $field = $this->field;
        $operator = $this->operator;
        $value = $this->value;

        $model = $this->getModel();
        if ($model !== null) {
            if (is_string($field)) {
                // shorthand for adding conditions on references
                // use chained reference names separated by "/"
                if (str_contains($field, '/')) {
                    $references = explode('/', $field);
                    $field = array_pop($references);

                    $refModels = [];
                    $refModel = $model;
                    foreach ($references as $link) {
                        $refModel = $refModel->refLink($link);
                        $refModels[] = $refModel;
                    }
                    unset($refModel);

                    foreach (array_reverse($refModels) as $refModel) {
                        if ($field === '#') {
                            if (is_string($value) && $value === (string) (int) $value) {
                                $value = (int) $value;
                            }

                            if ($value === 0) {
                                $field = $refModel->action('exists');
                                $value = false;
                            } elseif ($value === 1 && $operator === self::OPERATOR_GREATER_EQUAL) {
                                $field = $refModel->action('exists');
                                $operator = self::OPERATOR_EQUALS;
                                $value = true;
                            } else {
                                $field = $refModel->action('count');
                            }
                        } else {
                            $refModel->addCondition($field, $operator, $value);
                            $field = $refModel->action('exists');
                            $operator = self::OPERATOR_EQUALS;
                            $value = true;
                        }
                    }
                } else {
                    $field = $model->getField($field);
                }
            }

            // handle the query arguments using field
            if ($field instanceof Field) {
                [$field, $operator, $value] = $field->getQueryArguments($operator, $value);
            }

            // only expression contained in $field
            if (!$operator) {
                return [$field];
            }

            // skip explicitly using OPERATOR_EQUALS as in some cases it is transformed to OPERATOR_IN
            // for instance in DSQL so let exact operator be handled by Persistence
            if ($operator === self::OPERATOR_EQUALS) {
                return [$field, null, $value];
            }
        }

        return [$field, $operator, $value];
    }

    /**
     * @return false
     */
    #[\Override]
    public function isEmpty(): bool
    {
        return false;
    }

    /**
     * @return never
     */
    #[\Override]
    public function clear(): self
    {
        throw new Exception('Condition does not support clear operation');
    }

    #[\Override]
    public function negate(): self
    {
        if (isset(self::$operators[$this->operator]['negate'])) {
            $this->operator = self::$operators[$this->operator]['negate'];
        } else {
            throw (new Exception('Negation of condition is not supported for this operator'))
                ->addMoreInfo('operator', $this->operator);
        }

        return $this;
    }

    #[\Override]
    public function toWords(?Model $model = null): string
    {
        if ($model !== null) {
            $model->assertIsModel();
        } else {
            $model = $this->getModel();

            if ($model === null) {
                throw new Exception('Condition must be associated with model to convert to words');
            }
        }

        $field = $this->fieldToWords($model);
        $operator = $this->operatorToWords();
        $value = $this->valueToWords($model, $this->value);

        return trim($field . ' ' . $operator . ' ' . $value);
    }

    protected function fieldToWords(Model $model): string
    {
        $words = [];

        $field = $this->field;
        if (is_string($field)) {
            if (str_contains($field, '/')) {
                $references = explode('/', $field);

                $words[] = $model->getModelCaption();

                $field = array_pop($references);

                foreach ($references as $link) {
                    $words[] = 'that has reference ' . $this->readableCaption($link);

                    $model = $model->refLink($link);
                }

                $words[] = 'where';

                if ($field === '#') {
                    $words[] = $this->operator
                        ? 'number of records'
                        : 'any referenced record exists';
                }
            }

            if ($model->hasField($field)) {
                $field = $model->getField($field);
            }
        }

        if ($field instanceof Field) {
            $words[] = $field->getCaption();
        } elseif ($field instanceof Expressionable) {
            $words[] = $this->valueToWords($model, $field);
        }

        return implode(' ', array_filter($words, static fn ($v) => $v !== ''));
    }

    protected function operatorToWords(): string
    {
        return $this->operator
            ? self::$operators[$this->operator]['label']
            : '';
    }

    /**
     * @param mixed $value
     */
    protected function valueToWords(Model $model, $value): string
    {
        if ($value === null) {
            return $this->operator
                ? 'empty'
                : '';
        }

        if (is_array($value) && in_array($this->operator, [self::OPERATOR_IN, self::OPERATOR_NOT_IN], true)) {
            $res = [];
            foreach ($value as $v) {
                $thisCloned = clone $this;
                $thisCloned->operator = self::OPERATOR_EQUALS;
                $res[] = $thisCloned->valueToWords($model, $v);
            }

            return implode(' or ', $res);
        }

        if (is_object($value)) {
            if ($value instanceof Field) {
                return $value->getOwner()->getModelCaption() . ' ' . $value->getCaption();
            }

            if ($value instanceof Expressionable) {
                return 'expression \'' . $value->getDsqlExpression(new SqliteExpression())->getDebugQuery() . '\'';
            }
        }

        // handling of scope on references
        $field = $this->field;
        if (is_string($field)) {
            if (str_contains($field, '/')) {
                $references = explode('/', $field);

                $field = array_pop($references);

                foreach ($references as $link) {
                    $model = $model->refLink($link);
                }
            }

            if ($model->hasField($field)) {
                $field = $model->getField($field);
            }
        }

        // use the referenced model title if such exists
        $title = null;
        if ($field instanceof Field && $field->hasReference()) {
            // make sure we set the value in the Model
            $entity = $model->createEntity();
            $entity->set($field->shortName, $value);

            // then take the title
            $title = $entity->ref($field->getReference()->link)->getTitle();
            if ($title === $value) {
                $title = null;
            }
        }

        if (is_bool($value)) {
            $valueStr = $value ? 'true' : 'false';
        } elseif (is_int($value)) {
            $valueStr = (string) $value;
        } elseif (is_float($value)) {
            $valueStr = Expression::castFloatToString($value);
        } elseif (is_string($value)) {
            $valueStr = '\'' . $value . '\'';
        } else {
            $genericPersistence = new class extends Persistence {};
            $valueStr = $genericPersistence->typecastSaveField($field, $value); // @phpstan-ignore argument.type
        }

        return $valueStr . ($title !== null ? ' (\'' . $title . '\')' : '');
    }
}
