<?php

/**
 * @link https://www.yiiframework.com/
 * @copyright Copyright (c) 2008 Yii Software LLC
 * @license https://www.yiiframework.com/license/
 */

namespace yiiunit\framework\validators;

use Exception;
use Yii;
use yii\validators\UniqueValidator;
use yiiunit\data\ar\ActiveRecord;
use yiiunit\data\ar\Customer;
use yiiunit\data\ar\Document;
use yiiunit\data\ar\Order;
use yiiunit\data\ar\OrderItem;
use yiiunit\data\ar\Profile;
use yiiunit\data\validators\models\FakedValidationModel;
use yiiunit\data\validators\models\ValidatorTestMainModel;
use yiiunit\data\validators\models\ValidatorTestRefModel;
use yiiunit\framework\db\DatabaseTestCase;

abstract class UniqueValidatorTest extends DatabaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        ActiveRecord::$db = $this->getConnection();

        // destroy application, Validator must work without Yii::$app
        $this->destroyApplication();
    }

    public function testAssureMessageSetOnInit(): void
    {
        $val = new UniqueValidator();
        $this->assertIsString($val->message);
    }

    public function testCustomMessage(): void
    {
        // single attribute
        $customError = 'Custom message for Id with value "1"';
        $validator = new UniqueValidator([
            'message' => 'Custom message for {attribute} with value "{value}"',
        ]);
        $model = new Order();
        $model->id = 1;
        $validator->validateAttribute($model, 'id');
        $this->assertTrue($model->hasErrors('id'));
        $this->assertEquals($customError, $model->getFirstError('id'));

        // multiple attributes
        $customError = 'Custom message for Order Id and Item Id with values "1"-"1"';
        $validator = new UniqueValidator([
            'targetAttribute' => ['order_id', 'item_id'],
            'message' => 'Custom message for {attributes} with values {values}',
        ]);
        $model = OrderItem::findOne(['order_id' => 1, 'item_id' => 2]);
        $model->item_id = 1;
        $validator->validateAttribute($model, 'order_id');
        $this->assertTrue($model->hasErrors('order_id'));
        $this->assertEquals($customError, $model->getFirstError('order_id'));

        // fallback for deprecated `comboNotUnique` - should be removed on 2.1.0
        $validator = new UniqueValidator([
            'targetAttribute' => ['order_id', 'item_id'],
            'comboNotUnique' => 'Custom message for {attributes} with values {values}',
        ]);
        $model->clearErrors();
        $validator->validateAttribute($model, 'order_id');
        $this->assertTrue($model->hasErrors('order_id'));
        $this->assertEquals($customError, $model->getFirstError('order_id'));
    }

    public function testValidateInvalidAttribute(): void
    {
        $validator = new UniqueValidator();
        $messageError = Yii::t('yii', '{attribute} is invalid.', ['attribute' => 'Name']);

        /** @var Customer $customerModel */
        $customerModel = Customer::findOne(1);
        $customerModel->name = ['test array data'];
        $validator->validateAttribute($customerModel, 'name');
        $this->assertEquals($messageError, $customerModel->getFirstError('name'));

        $customerModel->clearErrors();

        $customerModel->name = 'test data';
        $customerModel->email = ['email@mail.com', 'email2@mail.com'];
        $validator->targetAttribute = ['email', 'name'];
        $validator->validateAttribute($customerModel, 'name');
        $this->assertEquals($messageError, $customerModel->getFirstError('name'));
    }

    public function testValidateAttributeDefault(): void
    {
        $val = new UniqueValidator();
        $m = ValidatorTestMainModel::find()->one();
        $val->validateAttribute($m, 'id');
        $this->assertFalse($m->hasErrors('id'));
        /** @var ValidatorTestRefModel $m */
        $m = ValidatorTestRefModel::findOne(1);
        $val->validateAttribute($m, 'ref');
        $this->assertTrue($m->hasErrors('ref'));
        // new record:
        $m = new ValidatorTestRefModel();
        $m->ref = 5;
        $val->validateAttribute($m, 'ref');
        $this->assertTrue($m->hasErrors('ref'));
        $m = new ValidatorTestRefModel();
        // Add id manual, there is no definition of sequence for the table.
        if ($this->driverName === 'oci') {
            $m->id = 7;
        }
        $m->ref = 12121;
        $val->validateAttribute($m, 'ref');
        $this->assertFalse($m->hasErrors('ref'));
        $m->save(false);
        $val->validateAttribute($m, 'ref');
        $this->assertFalse($m->hasErrors('ref'));
        // array error
        $m = FakedValidationModel::createWithAttributes(['attr_arr' => ['a', 'b']]);
        $val->validateAttribute($m, 'attr_arr');
        $this->assertTrue($m->hasErrors('attr_arr'));
    }

    public function testValidateAttributeOfNonARModel(): void
    {
        $val = new UniqueValidator(['targetClass' => ValidatorTestRefModel::class, 'targetAttribute' => 'ref']);
        $m = FakedValidationModel::createWithAttributes(['attr_1' => 5, 'attr_2' => 1313]);
        $val->validateAttribute($m, 'attr_1');
        $this->assertTrue($m->hasErrors('attr_1'));
        $val->validateAttribute($m, 'attr_2');
        $this->assertFalse($m->hasErrors('attr_2'));
    }

    public function testValidateNonDatabaseAttribute(): void
    {
        $val = new UniqueValidator(['targetClass' => ValidatorTestRefModel::class, 'targetAttribute' => 'ref']);
        /** @var ValidatorTestMainModel $m */
        $m = ValidatorTestMainModel::findOne(1);
        $val->validateAttribute($m, 'testMainVal');
        $this->assertFalse($m->hasErrors('testMainVal'));
        $m = ValidatorTestMainModel::findOne(1);
        $m->testMainVal = 4;
        $val->validateAttribute($m, 'testMainVal');
        $this->assertTrue($m->hasErrors('testMainVal'));
    }

    public function testValidateAttributeAttributeNotInTableException(): void
    {
        $this->expectException('yii\db\Exception');
        $val = new UniqueValidator();
        $m = new ValidatorTestMainModel();
        $val->validateAttribute($m, 'testMainVal');
    }

    public function testValidateCompositeKeys(): void
    {
        $val = new UniqueValidator([
            'targetClass' => OrderItem::class,
            'targetAttribute' => ['order_id', 'item_id'],
        ]);
        // validate old record
        /** @var OrderItem $m */
        $m = OrderItem::findOne(['order_id' => 1, 'item_id' => 2]);
        $val->validateAttribute($m, 'order_id');
        $this->assertFalse($m->hasErrors('order_id'));
        $m->item_id = 1;
        $val->validateAttribute($m, 'order_id');
        $this->assertTrue($m->hasErrors('order_id'));
        $this->assertStringStartsWith('The combination "1"-"1" of Order Id and Item Id', $m->getFirstError('order_id'));

        // validate new record
        $m = new OrderItem(['order_id' => 1, 'item_id' => 2]);
        $val->validateAttribute($m, 'order_id');
        $this->assertTrue($m->hasErrors('order_id'));
        $this->assertStringStartsWith('The combination "1"-"2" of Order Id and Item Id', $m->getFirstError('order_id'));
        $m = new OrderItem(['order_id' => 10, 'item_id' => 2]);
        $val->validateAttribute($m, 'order_id');
        $this->assertFalse($m->hasErrors('order_id'));

        $val = new UniqueValidator([
            'targetClass' => OrderItem::class,
            'targetAttribute' => ['id' => 'order_id'],
        ]);
        // validate old record
        /** @var Order $m */
        $m = Order::findOne(1);
        $val->validateAttribute($m, 'id');
        $this->assertTrue($m->hasErrors('id'));
        $this->assertStringStartsWith('Id "1" has already been taken.', $m->getFirstError('id'));
        $m = Order::findOne(1);
        $m->id = 2;
        $val->validateAttribute($m, 'id');
        $this->assertTrue($m->hasErrors('id'));
        $this->assertStringStartsWith('Id "2" has already been taken.', $m->getFirstError('id'));
        $m = Order::findOne(1);
        $m->id = 10;
        $val->validateAttribute($m, 'id');
        $this->assertFalse($m->hasErrors('id'));

        $m = new Order(['id' => 1]);
        $val->validateAttribute($m, 'id');
        $this->assertTrue($m->hasErrors('id'));
        $this->assertStringStartsWith('Id "1" has already been taken.', $m->getFirstError('id'));
        $m = new Order(['id' => 10]);
        $val->validateAttribute($m, 'id');
        $this->assertFalse($m->hasErrors('id'));
    }

    public function testValidateTargetClass(): void
    {
        // Check whether "Description" and "name" aren't equal
        $val = new UniqueValidator([
            'targetClass' => Customer::class,
            'targetAttribute' => ['description' => 'name'],
        ]);

        /** @var Profile $m */
        $m = Profile::findOne(1);
        $this->assertEquals('profile customer 1', $m->description);
        $val->validateAttribute($m, 'description');
        $this->assertFalse($m->hasErrors('description'));

        // ID of Profile is not equal to ID of Customer
        // (1, description = user2) <=> (2, name = user2)
        $m->description = 'user2';
        $val->validateAttribute($m, 'description');
        $this->assertTrue($m->hasErrors('description'));
        $m->clearErrors('description');

        // ID of Profile IS equal to ID of Customer
        // (1, description = user1) <=> (1, name = user1)
        // https://github.com/yiisoft/yii2/issues/10263
        $m->description = 'user1';
        $val->validateAttribute($m, 'description');
        $this->assertTrue($m->hasErrors('description'));
    }

    public function testValidateScopeNamespaceTargetClassForNewClass(): void
    {
        $validator = new UniqueValidator();

        /** @var Profile $profileModel */
        $profileModel = new Profile(['description' => 'profile customer 1']);
        $validator->validateAttribute($profileModel, 'description');
        $this->assertTrue($profileModel->hasErrors('description'));

        $profileModel->clearErrors();
        $validator->targetClass = 'yiiunit\data\ar\Profile';
        $validator->validateAttribute($profileModel, 'description');
        $this->assertTrue($profileModel->hasErrors('description'));

        $profileModel->clearErrors();
        $validator->targetClass = '\yiiunit\data\ar\Profile';
        $validator->validateAttribute($profileModel, 'description');
        $this->assertTrue($profileModel->hasErrors('description'));
    }

    public function testValidateScopeNamespaceTargetClass(): void
    {
        $validator = new UniqueValidator();

        /** @var Profile $profileModel */
        $profileModel = Profile::findOne(1);
        $validator->validateAttribute($profileModel, 'description');
        $this->assertFalse($profileModel->hasErrors('description'));

        $profileModel->clearErrors();
        $validator->targetClass = 'yiiunit\data\ar\Profile';
        $validator->validateAttribute($profileModel, 'description');
        $this->assertFalse($profileModel->hasErrors('description'));

        $profileModel->clearErrors();
        $validator->targetClass = '\yiiunit\data\ar\Profile';
        $validator->validateAttribute($profileModel, 'description');
        $this->assertFalse($profileModel->hasErrors('description'));
    }

    public function testValidateEmptyAttributeInStringField(): void
    {
        ValidatorTestMainModel::deleteAll();

        $val = new UniqueValidator();

        $m = new ValidatorTestMainModel(['field1' => '']);

        // Add id manual, there is no definition of sequence for the table.
        if ($this->driverName === 'oci') {
            $m->id = 5;
        }

        $val->validateAttribute($m, 'field1');
        $this->assertFalse($m->hasErrors('field1'));
        $m->save(false);

        $m = new ValidatorTestMainModel(['field1' => '']);
        $val->validateAttribute($m, 'field1');
        $this->assertTrue($m->hasErrors('field1'));
    }

    public function testValidateEmptyAttributeInIntField(): void
    {
        ValidatorTestRefModel::deleteAll();

        $val = new UniqueValidator();

        $m = new ValidatorTestRefModel(['ref' => 0]);

        // Add id manual, there is no definition of sequence for the table.
        if ($this->driverName === 'oci') {
            $m->id = 6;
        }

        $val->validateAttribute($m, 'ref');
        $this->assertFalse($m->hasErrors('ref'));
        $m->save(false);

        $m = new ValidatorTestRefModel(['ref' => 0]);
        $val->validateAttribute($m, 'ref');
        $this->assertTrue($m->hasErrors('ref'));
    }

    public function testPrepareParams(): void
    {
        $model = new FakedValidationModel();
        $model->val_attr_a = 'test value a';
        $model->val_attr_b = 'test value b';
        $model->val_attr_c = 'test value c';
        $attribute = 'val_attr_a';

        $targetAttribute = 'val_attr_b';
        $result = $this->invokeMethod(new UniqueValidator(), 'prepareConditions', [$targetAttribute, $model, $attribute]);
        $expected = ['val_attr_b' => 'test value a'];
        $this->assertEquals($expected, $result);

        $targetAttribute = ['val_attr_b', 'val_attr_c'];
        $result = $this->invokeMethod(new UniqueValidator(), 'prepareConditions', [$targetAttribute, $model, $attribute]);
        $expected = ['val_attr_b' => 'test value b', 'val_attr_c' => 'test value c'];
        $this->assertEquals($expected, $result);

        $targetAttribute = ['val_attr_a' => 'val_attr_b'];
        $result = $this->invokeMethod(new UniqueValidator(), 'prepareConditions', [$targetAttribute, $model, $attribute]);
        $expected = ['val_attr_b' => 'test value a'];
        $this->assertEquals($expected, $result);

        $targetAttribute = ['val_attr_b', 'val_attr_a' => 'val_attr_c'];
        $result = $this->invokeMethod(new UniqueValidator(), 'prepareConditions', [$targetAttribute, $model, $attribute]);
        $expected = ['val_attr_b' => 'test value b', 'val_attr_c' => 'test value a'];
        $this->assertEquals($expected, $result);

        // Add table prefix for column name
        $model = Profile::findOne(1);
        $attribute = 'id';
        $targetAttribute = 'id';
        $result = $this->invokeMethod(new UniqueValidator(), 'prepareConditions', [$targetAttribute, $model, $attribute]);
        $expected = ['{{' . Profile::tableName() . '}}.[[' . $attribute . ']]' => $model->id];
        $this->assertEquals($expected, $result);
    }

    public function testGetTargetClassWithFilledTargetClassProperty(): void
    {
        $validator = new UniqueValidator(['targetClass' => Profile::class]);
        $model = new FakedValidationModel();
        $actualTargetClass = $this->invokeMethod($validator, 'getTargetClass', [$model]);

        $this->assertEquals(Profile::class, $actualTargetClass);
    }

    public function testGetTargetClassWithNotFilledTargetClassProperty(): void
    {
        $validator = new UniqueValidator();
        $model = new FakedValidationModel();
        $actualTargetClass = $this->invokeMethod($validator, 'getTargetClass', [$model]);

        $this->assertEquals(FakedValidationModel::class, $actualTargetClass);
    }

    public function testPrepareQuery(): void
    {
        $schema = $this->getConnection()->schema;

        $model = new ValidatorTestMainModel();
        $query = $this->invokeMethod(new UniqueValidator(), 'prepareQuery', [$model, ['val_attr_b' => 'test value a']]);
        $expected = "SELECT * FROM {$schema->quoteTableName('validator_main')} WHERE {$schema->quoteColumnName('val_attr_b')}=:qp0";
        $this->assertEquals($expected, $query->createCommand()->getSql());

        $params = ['val_attr_b' => 'test value b', 'val_attr_c' => 'test value a'];
        $query = $this->invokeMethod(new UniqueValidator(), 'prepareQuery', [$model, $params]);
        $expected = "SELECT * FROM {$schema->quoteTableName('validator_main')} WHERE ({$schema->quoteColumnName('val_attr_b')}=:qp0) AND ({$schema->quoteColumnName('val_attr_c')}=:qp1)";
        $this->assertEquals($expected, $query->createCommand()->getSql());

        $params = ['val_attr_b' => 'test value b'];
        $query = $this->invokeMethod(new UniqueValidator(['filter' => 'val_attr_a > 0']), 'prepareQuery', [$model, $params]);
        $expected = "SELECT * FROM {$schema->quoteTableName('validator_main')} WHERE ({$schema->quoteColumnName('val_attr_b')}=:qp0) AND (val_attr_a > 0)";
        $this->assertEquals($expected, $query->createCommand()->getSql());

        $params = ['val_attr_b' => 'test value b'];
        $query = $this->invokeMethod(new UniqueValidator(['filter' => function ($query): void {
            $query->orWhere('val_attr_a > 0');
        }]), 'prepareQuery', [$model, $params]);
        $expected = "SELECT * FROM {$schema->quoteTableName('validator_main')} WHERE ({$schema->quoteColumnName('val_attr_b')}=:qp0) OR (val_attr_a > 0)";
        $this->assertEquals($expected, $query->createCommand()->getSql());
    }

    /**
     * Test ambiguous column name in select clause.
     * @see https://github.com/yiisoft/yii2/issues/14042
     */
    public function testAmbiguousColumnName(): void
    {
        $validator = new UniqueValidator([
            'filter' => function ($query): void {
                $query->joinWith('items', false);
            },
        ]);
        $model = new Order();
        $model->customer_id = 1;
        $model->total = 800;
        $model->save(false);
        $validator->validateAttribute($model, 'id');
        $this->assertFalse($model->hasErrors());
    }

    /**
     * Test expression in targetAttribute.
     * @see https://github.com/yiisoft/yii2/issues/14304
     */
    public function testExpressionInAttributeColumnName(): void
    {
        $validator = new UniqueValidator([
            'targetAttribute' => [
                'title' => 'LOWER([[title]])',
            ],
        ]);
        $model = new Document();
        $model->title = 'Test';
        $model->content = 'test';
        $model->version = 1;
        $model->save(false);
        $validator->validateAttribute($model, 'title');
        $this->assertFalse($model->hasErrors(), 'There were errors: ' . json_encode($model->getErrors(), JSON_THROW_ON_ERROR));
    }

    /**
     * Test validating a class with default scope
     * @see https://github.com/yiisoft/yii2/issues/14484
    */
    public function testFindModelWith(): void
    {
        $validator = new UniqueValidator([
            'targetAttribute' => ['status', 'profile_id']
        ]);
        $model = WithCustomer::find()->one();
        try {
            $validator->validateAttribute($model, 'email');
            $this->assertTrue(true);
        } catch (Exception) {
            $this->fail('Query is crashed because "with" relation cannot be loaded');
        }
    }

    /**
     * Test join with doesn't attempt to eager load joinWith relations
     * @see https://github.com/yiisoft/yii2/issues/17389
     */
    public function testFindModelJoinWith(): void
    {
        $validator = new UniqueValidator([
            'targetAttribute' => ['status', 'profile_id'],
        ]);
        $model = JoinWithCustomer::find()->one();
        try {
            $validator->validateAttribute($model, 'email');
            $this->assertTrue(true);
        } catch (Exception) {
            $this->fail('Query is crashed because "joinWith" relation cannot be loaded');
        }
    }

    public function testForceMaster(): void
    {
        $connection = $this->getConnectionWithInvalidSlave();
        ActiveRecord::$db = $connection;

        $model = null;
        $connection->useMaster(function () use (&$model) {
            $model = WithCustomer::find()->one();
        });

        $validator = new UniqueValidator([
            'forceMasterDb' => true,
            'targetAttribute' => ['status', 'profile_id']
        ]);
        $validator->validateAttribute($model, 'email');

        $this->expectException('\yii\base\InvalidConfigException');
        $validator = new UniqueValidator([
            'forceMasterDb' => false,
            'targetAttribute' => ['status', 'profile_id']
        ]);
        $validator->validateAttribute($model, 'email');

        ActiveRecord::$db = $this->getConnection();
    }

    public function testSecondTargetAttributeWithError(): void
    {
        $validator = new UniqueValidator(['targetAttribute' => ['email', 'name']]);
        $customer = new Customer();
        $customer->email = 'user1@example.com';
        $customer->name = 'user1';

        $validator->validateAttribute($customer, 'email');
        $this->assertTrue($customer->hasErrors('email'));

        $customer->clearErrors();

        $customer->addError('name', 'error');
        $validator->validateAttribute($customer, 'email');
        $this->assertFalse($customer->hasErrors('email')); // validator should be skipped

        $validator = new UniqueValidator([
            'targetAttribute' => ['email', 'name'],
            'skipOnError' => false,
        ]);

        $customer->clearErrors();
        $customer->addError('name', 'error');
        $validator->validateAttribute($customer, 'email');
        $this->assertTrue($customer->hasErrors('email')); // validator should not be skipped
    }
}

class WithCustomer extends Customer
{
    public static function find()
    {
        $res = parent::find();

        $res->with('profile');

        return $res;
    }
}

class JoinWithCustomer extends Customer
{
    public static function find()
    {
        $res = parent::find();

        $res->joinWith('profile');

        return $res;
    }
}
