<?php

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

declare(strict_types=1);

namespace yiiunit\framework\behaviors;

use ValueError;
use Yii;
use yii\base\DynamicModel;
use yii\base\Event;
use yii\behaviors\AttributeTypecastBehavior;
use yii\db\ActiveRecord;
use yiiunit\framework\db\enums\StatusTypeString;
use yiiunit\TestCase;

/**
 * Unit test for [[\yii\behaviors\AttributeTypecastBehavior]].
 * @see AttributeTypecastBehavior
 *
 * @group behaviors
 */
class AttributeTypecastBehaviorTest extends TestCase
{
    public static function setUpBeforeClass(): void
    {
        if (!extension_loaded('pdo') || !extension_loaded('pdo_sqlite')) {
            static::markTestSkipped('PDO and SQLite extensions are required.');
        }
    }

    protected function setUp(): void
    {
        $this->mockApplication([
            'components' => [
                'db' => [
                    'class' => '\yii\db\Connection',
                    'dsn' => 'sqlite::memory:',
                ],
            ],
        ]);

        $columns = [
            'id' => 'pk',
            'name' => 'string',
            'amount' => 'integer',
            'price' => 'float',
            'isActive' => 'boolean',
            'callback' => 'string',
            'status' => 'string',
        ];
        Yii::$app->getDb()->createCommand()->createTable('test_attribute_typecast', $columns)->execute();
    }

    protected function tearDown(): void
    {
        parent::tearDown();
        AttributeTypecastBehavior::clearAutoDetectedAttributeTypes();
        gc_enable();
        gc_collect_cycles();
    }

    // Tests :

    public function testTypecast(): void
    {
        $model = new ActiveRecordAttributeTypecast();

        $model->name = 123;
        $model->amount = '58';
        $model->price = '100.8';
        $model->isActive = 1;
        $model->callback = 'foo';

        $model->getAttributeTypecastBehavior()->typecastAttributes();

        $this->assertSame('123', $model->name);
        $this->assertSame(58, $model->amount);
        $this->assertSame(100.8, $model->price);
        $this->assertTrue($model->isActive);
        $this->assertSame('callback: foo', $model->callback);
    }

    public function testTypecastEnum(): void
    {
        $model = new ActiveRecordAttributeTypecastWithEnum();

        $model->status = StatusTypeString::Active;

        $model->getAttributeTypecastBehavior()->typecastAttributes();

        $this->assertSame(StatusTypeString::Active, $model->status);
    }

    /**
     * @depends testTypecastEnum
     */
    public function testTypecastEnumFromString(): void
    {
        $model = new ActiveRecordAttributeTypecastWithEnum();
        $model->status = 'active'; // Same as StatusTypeString::ACTIVE->value;

        $model->getAttributeTypecastBehavior()->typecastAttributes();

        $this->assertSame(StatusTypeString::Active, $model->status);
    }

    /**
     * @depends testTypecastEnum
     */
    public function testTypecastEnumFailWithInvalidValue(): void
    {
        $model = new ActiveRecordAttributeTypecastWithEnum();
        $model->status = 'invalid';

        self::expectException(ValueError::class);

        $model->getAttributeTypecastBehavior()->typecastAttributes();
    }

    /**
     * @depends testTypecast
     */
    public function testSkipNull(): void
    {
        $model = new ActiveRecordAttributeTypecast();
        $model->getAttributeTypecastBehavior()->skipOnNull = true;

        $model->name = null;
        $model->amount = null;
        $model->price = null;
        $model->isActive = null;
        $model->callback = null;

        $model->getAttributeTypecastBehavior()->typecastAttributes();

        $this->assertNull($model->name);
        $this->assertNull($model->amount);
        $this->assertNull($model->price);
        $this->assertNull($model->isActive);
        $this->assertNull($model->callback);

        $model->getAttributeTypecastBehavior()->skipOnNull = false;
        $model->getAttributeTypecastBehavior()->typecastAttributes();

        $this->assertSame('', $model->name);
        $this->assertSame(0, $model->amount);
        $this->assertSame(0.0, $model->price);
        $this->assertFalse($model->isActive);
        $this->assertSame('callback: ', $model->callback);
    }

    /**
     * @depends testTypecast
     */
    public function testAfterFindEvent(): void
    {
        $model = new ActiveRecordAttributeTypecast();

        $model->validate();
        $model->save(false);

        $model->updateAll(['callback' => 'find']);
        $model->refresh();
        $this->assertSame('callback: find', $model->callback);
    }

    /**
     * @see https://github.com/yiisoft/yii2/issues/17194
     */
    public function testDirtyAttributesAreEmptyAfterFind(): void
    {
        $model = new ActiveRecordAttributeTypecast();
        $model->name = 123;
        $model->amount = '58';
        $model->price = '100.8';
        $model->isActive = 1;
        $model->callback = 'foo';
        $model->save(false);

        $model = ActiveRecordAttributeTypecast::find()->one();

        $this->assertEmpty($model->getDirtyAttributes());
    }

    /**
     * @depends testTypecast
     */
    public function testAfterValidateEvent(): void
    {
        $model = new ActiveRecordAttributeTypecast();

        $model->callback = 'validate';
        $model->validate();
        $this->assertSame('callback: validate', $model->callback);
    }

    /**
     * @depends testTypecast
     */
    public function testBeforeSaveEvent(): void
    {
        $model = new ActiveRecordAttributeTypecast();

        $beforeInsertHappened = false;
        $model->callback = 'insert';
        $model->on(ActiveRecordAttributeTypecast::EVENT_BEFORE_INSERT, function (Event $event) use (&$beforeInsertHappened): void {
            $beforeInsertHappened = true;
        });
        $model->save(false);
        $this->assertSame('callback: insert', $model->callback);
        $this->assertTrue($beforeInsertHappened);
        $beforeInsertHappened = false;

        $beforeUpdateHappened = false;
        $model->callback = 'update';
        $model->on(ActiveRecordAttributeTypecast::EVENT_BEFORE_UPDATE, function (Event $event) use (&$beforeUpdateHappened): void {
            $beforeUpdateHappened = true;
        });
        $model->save(false);
        $this->assertSame('callback: update', $model->callback);
        $this->assertTrue($beforeUpdateHappened);
        $this->assertFalse($beforeInsertHappened);
    }

    /**
     * @depends testTypecast
     */
    public function testAfterSaveEvent(): void
    {
        $model = new ActiveRecordAttributeTypecast([
            'typecastAfterSave' => true
        ]);

        $model->callback = 'insert';

        $beforeInsertHappened = false;
        $model->on(ActiveRecordAttributeTypecast::EVENT_BEFORE_INSERT, function (Event $event) use (&$beforeInsertHappened): void {
            $beforeInsertHappened = true;
        });
        $afterInsertHappened = false;
        $model->on(ActiveRecordAttributeTypecast::EVENT_AFTER_INSERT, function (Event $event) use (&$afterInsertHappened): void {
            $afterInsertHappened = true;
        });
        $model->save(false);
        $this->assertTrue($beforeInsertHappened);
        $this->assertTrue($afterInsertHappened);
        $this->assertSame('callback: callback: insert', $model->callback);
        $beforeInsertHappened = $afterInsertHappened = false;


        $model->callback = 'update';
        $beforeUpdateHappened = false;
        $model->on(ActiveRecordAttributeTypecast::EVENT_BEFORE_UPDATE, function (Event $event) use (&$beforeUpdateHappened): void {
            $beforeUpdateHappened = true;
        });
        $afterUpdateHappened = false;
        $model->on(ActiveRecordAttributeTypecast::EVENT_AFTER_UPDATE, function (Event $event) use (&$afterUpdateHappened): void {
            $afterUpdateHappened = true;
        });
        $model->save(false);
        $this->assertSame('callback: callback: update', $model->callback);
        $this->assertTrue($beforeUpdateHappened);
        $this->assertTrue($afterUpdateHappened);
        $this->assertFalse($beforeInsertHappened);
        $this->assertFalse($afterInsertHappened);
    }

    public function testAutoDetectAttributeTypes(): void
    {
        $model = (new DynamicModel(['name' => null, 'amount' => null, 'price' => null, 'isActive' => null]))
            ->addRule('name', 'string')
            ->addRule('amount', 'integer')
            ->addRule('price', 'number')
            ->addRule('!isActive', 'boolean');

        $behavior = new AttributeTypecastBehavior();

        $behavior->attach($model);

        $expectedAttributeTypes = [
            'name' => AttributeTypecastBehavior::TYPE_STRING,
            'amount' => AttributeTypecastBehavior::TYPE_INTEGER,
            'price' => AttributeTypecastBehavior::TYPE_FLOAT,
            'isActive' => AttributeTypecastBehavior::TYPE_BOOLEAN,
        ];
        $this->assertEquals($expectedAttributeTypes, $behavior->attributeTypes);
    }

    /**
     * @depends testSkipNull
     *
     * @see https://github.com/yiisoft/yii2/issues/12880
     */
    public function testSkipNotSelectedAttribute(): void
    {
        $model = new ActiveRecordAttributeTypecast();
        $model->name = 'skip-not-selected';
        $model->amount = '58';
        $model->price = '100.8';
        $model->isActive = 1;
        $model->callback = 'foo';
        $model->save(false);

        /** @var ActiveRecordAttributeTypecast $model */
        $model = ActiveRecordAttributeTypecast::find()
            ->select(['id', 'name'])
            ->limit(1)
            ->one();

        $model->getAttributeTypecastBehavior()->typecastAttributes();
        $model->save(false);

        $model->refresh();
        $this->assertSame(58, $model->amount);
    }
}

/**
 * Test Active Record class with [[AttributeTypecastBehavior]] behavior attached.
 *
 * @property int $id
 * @property string $name
 * @property int $amount
 * @property float $price
 * @property bool $isActive
 * @property string $callback
 *
 * @property AttributeTypecastBehavior $attributeTypecastBehavior
 */
class ActiveRecordAttributeTypecast extends ActiveRecord
{
    public $typecastAfterSave = false;

    public function behaviors()
    {
        return [
            'attributeTypecast' => [
                'class' => AttributeTypecastBehavior::class,
                'attributeTypes' => [
                    'name' => AttributeTypecastBehavior::TYPE_STRING,
                    'amount' => AttributeTypecastBehavior::TYPE_INTEGER,
                    'price' => AttributeTypecastBehavior::TYPE_FLOAT,
                    'isActive' => AttributeTypecastBehavior::TYPE_BOOLEAN,
                    'callback' => fn($value) => 'callback: ' . $value,
                ],
                'typecastAfterValidate' => true,
                'typecastBeforeSave' => true,
                'typecastAfterFind' => true,
                'typecastAfterSave' => $this->typecastAfterSave,
            ],
        ];
    }

    public static function tableName()
    {
        return 'test_attribute_typecast';
    }

    public function rules()
    {
        return [
            ['name', 'string'],
            ['amount', 'integer'],
            ['price', 'number'],
            ['isActive', 'boolean'],
        ];
    }

    /**
     * @return AttributeTypecastBehavior
     */
    public function getAttributeTypecastBehavior()
    {
        return $this->getBehavior('attributeTypecast');
    }
}

/**
 * Test Active Record class with [[AttributeTypecastBehavior]] behavior attached with an enum field.
 *
 * @property StatusTypeString $status
 */
class ActiveRecordAttributeTypecastWithEnum extends ActiveRecord
{
    public function behaviors()
    {
        return [
            'attributeTypecast' => [
                'class' => AttributeTypecastBehavior::class,
                'attributeTypes' => [
                    'status' => StatusTypeString::class,
                ],
                'typecastBeforeSave' => true,
            ],
        ];
    }

    public static function tableName()
    {
        return 'test_attribute_typecast';
    }

    /**
     * @return AttributeTypecastBehavior
     */
    public function getAttributeTypecastBehavior()
    {
        return $this->getBehavior('attributeTypecast');
    }
}
