<?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         3.0.0
 * @license       https://opensource.org/licenses/mit-license.php MIT License
 */
namespace Cake\Test\TestCase\ORM\Association;

use Cake\Database\Expression\IdentifierExpression;
use Cake\Database\Expression\QueryExpression;
use Cake\Database\TypeMap;
use Cake\ORM\Association\HasOne;
use Cake\ORM\Entity;
use Cake\TestSuite\TestCase;

/**
 * Tests HasOne class
 */
class HasOneTest extends TestCase
{
    /**
     * Fixtures to load
     *
     * @var array
     */
    protected $fixtures = ['core.Users', 'core.Profiles'];

    /**
     * @var bool
     */
    protected $listenerCalled = false;

    /**
     * Set up
     *
     * @return void
     */
    public function setUp(): void
    {
        parent::setUp();
        $this->user = $this->getTableLocator()->get('Users');
        $this->profile = $this->getTableLocator()->get('Profiles');
        $this->listenerCalled = false;
    }

    /**
     * Tests that setForeignKey() returns the correct configured value
     *
     * @return void
     */
    public function testSetForeignKey()
    {
        $assoc = new HasOne('Profiles', [
            'sourceTable' => $this->user,
        ]);
        $this->assertSame('user_id', $assoc->getForeignKey());
        $this->assertEquals($assoc, $assoc->setForeignKey('another_key'));
        $this->assertSame('another_key', $assoc->getForeignKey());
    }

    /**
     * Tests that the association reports it can be joined
     *
     * @return void
     */
    public function testCanBeJoined()
    {
        $assoc = new HasOne('Test');
        $this->assertTrue($assoc->canBeJoined());
    }

    /**
     * Tests that the correct join and fields are attached to a query depending on
     * the association config
     *
     * @return void
     */
    public function testAttachTo()
    {
        $config = [
            'foreignKey' => 'user_id',
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
            'property' => 'profile',
            'joinType' => 'INNER',
            'conditions' => ['Profiles.is_active' => true],
        ];
        $association = new HasOne('Profiles', $config);
        $query = $this->user->find();
        $association->attachTo($query);

        $results = $query->order('Users.id')->toArray();
        $this->assertCount(1, $results, 'Only one record because of conditions & join type');
        $this->assertSame('masters', $results[0]->Profiles['last_name']);
    }

    /**
     * Tests that it is possible to avoid fields inclusion for the associated table
     *
     * @return void
     */
    public function testAttachToNoFields()
    {
        $config = [
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
            'conditions' => ['Profiles.is_active' => true],
        ];
        $association = new HasOne('Profiles', $config);
        $query = $this->user->query();
        $association->attachTo($query, ['includeFields' => false]);
        $this->assertEmpty($query->clause('select'));
    }

    /**
     * Tests that using hasOne with a table having a multi column primary
     * key will work if the foreign key is passed
     *
     * @return void
     */
    public function testAttachToMultiPrimaryKey()
    {
        $selectTypeMap = new TypeMap([
            'Profiles.id' => 'integer',
            'id' => 'integer',
            'Profiles.first_name' => 'string',
            'first_name' => 'string',
            'Profiles.user_id' => 'integer',
            'user_id' => 'integer',
            'Profiles__first_name' => 'string',
            'Profiles__user_id' => 'integer',
            'Profiles__id' => 'integer',
            'Profiles__last_name' => 'string',
            'Profiles.last_name' => 'string',
            'last_name' => 'string',
            'Profiles__is_active' => 'boolean',
            'Profiles.is_active' => 'boolean',
            'is_active' => 'boolean',
        ]);
        $config = [
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
            'conditions' => ['Profiles.is_active' => true],
            'foreignKey' => ['user_id', 'user_site_id'],
        ];

        $this->user->setPrimaryKey(['id', 'site_id']);
        $association = new HasOne('Profiles', $config);

        $query = $this->getMockBuilder('Cake\ORM\Query')
            ->onlyMethods(['join', 'select'])
            ->disableOriginalConstructor()
            ->getMock();
        $field1 = new IdentifierExpression('Profiles.user_id');
        $field2 = new IdentifierExpression('Profiles.user_site_id');
        $query->expects($this->once())->method('join')->with([
            'Profiles' => [
                'conditions' => new QueryExpression([
                    'Profiles.is_active' => true,
                    ['Users.id' => $field1, 'Users.site_id' => $field2],
                ], $selectTypeMap),
                'type' => 'LEFT',
                'table' => 'profiles',
            ],
        ]);
        $query->expects($this->never())->method('select');
        $association->attachTo($query, ['includeFields' => false]);
    }

    /**
     * Tests that using hasOne with a table having a multi column primary
     * key will work if the foreign key is passed
     *
     * @return void
     */
    public function testAttachToMultiPrimaryKeyMismatch()
    {
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Cannot match provided foreignKey for "Profiles", got "(user_id)" but expected foreign key for "(id, site_id)"');
        $query = $this->getMockBuilder('Cake\ORM\Query')
            ->onlyMethods(['join', 'select'])
            ->disableOriginalConstructor()
            ->getMock();
        $config = [
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
            'conditions' => ['Profiles.is_active' => true],
        ];
        $this->user->setPrimaryKey(['id', 'site_id']);
        $association = new HasOne('Profiles', $config);
        $association->attachTo($query, ['includeFields' => false]);
    }

    /**
     * Test that saveAssociated() ignores non entity values.
     *
     * @return void
     */
    public function testSaveAssociatedOnlyEntities()
    {
        $mock = $this->getMockBuilder('Cake\ORM\Table')
            ->addMethods(['saveAssociated'])
            ->disableOriginalConstructor()
            ->getMock();
        $config = [
            'sourceTable' => $this->user,
            'targetTable' => $mock,
        ];
        $mock->expects($this->never())
            ->method('saveAssociated');

        $entity = new Entity([
            'username' => 'Mark',
            'email' => 'mark@example.com',
            'profile' => ['twitter' => '@cakephp'],
        ]);

        $association = new HasOne('Profiles', $config);
        $result = $association->saveAssociated($entity);

        $this->assertSame($result, $entity);
    }

    /**
     * Tests that property is being set using the constructor options.
     *
     * @return void
     */
    public function testPropertyOption()
    {
        $config = ['propertyName' => 'thing_placeholder'];
        $association = new HasOne('Thing', $config);
        $this->assertSame('thing_placeholder', $association->getProperty());
    }

    /**
     * Test that plugin names are omitted from property()
     *
     * @return void
     */
    public function testPropertyNoPlugin()
    {
        $config = [
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
        ];
        $association = new HasOne('Contacts.Profiles', $config);
        $this->assertSame('profile', $association->getProperty());
    }

    /**
     * Tests that attaching an association to a query will trigger beforeFind
     * for the target table
     *
     * @return void
     */
    public function testAttachToBeforeFind()
    {
        $config = [
            'foreignKey' => 'user_id',
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
        ];
        $query = $this->user->query();

        $this->listenerCalled = false;
        $this->profile->getEventManager()->on('Model.beforeFind', function ($event, $query, $options, $primary) {
            $this->listenerCalled = true;
            $this->assertInstanceOf('Cake\Event\Event', $event);
            $this->assertInstanceOf('Cake\ORM\Query', $query);
            $this->assertInstanceOf('ArrayObject', $options);
            $this->assertFalse($primary);
        });
        $association = new HasOne('Profiles', $config);
        $association->attachTo($query);
        $this->assertTrue($this->listenerCalled, 'beforeFind event not fired.');
    }

    /**
     * Tests that attaching an association to a query will trigger beforeFind
     * for the target table
     *
     * @return void
     */
    public function testAttachToBeforeFindExtraOptions()
    {
        $config = [
            'foreignKey' => 'user_id',
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
        ];
        $this->listenerCalled = false;
        $opts = new \ArrayObject(['something' => 'more']);
        $this->profile->getEventManager()->on(
            'Model.beforeFind',
            function ($event, $query, $options, $primary) use ($opts) {
                $this->listenerCalled = true;
                $this->assertInstanceOf('Cake\Event\Event', $event);
                $this->assertInstanceOf('Cake\ORM\Query', $query);
                $this->assertEquals($options, $opts);
                $this->assertFalse($primary);
            }
        );
        $association = new HasOne('Profiles', $config);
        $query = $this->user->find();
        $association->attachTo($query, ['queryBuilder' => function ($q) {
            return $q->applyOptions(['something' => 'more']);
        }]);
        $this->assertTrue($this->listenerCalled, 'Event not fired');
    }

    /**
     * Test cascading deletes.
     *
     * @return void
     */
    public function testCascadeDelete()
    {
        $config = [
            'dependent' => true,
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
            'conditions' => ['Profiles.is_active' => true],
            'cascadeCallbacks' => false,
        ];
        $association = new HasOne('Profiles', $config);

        $this->profile->getEventManager()->on('Model.beforeDelete', function () {
            $this->fail('Callbacks should not be triggered when callbacks do not cascade.');
        });

        $entity = new Entity(['id' => 1]);
        $association->cascadeDelete($entity);

        $query = $this->profile->query()->where(['user_id' => 1]);
        $this->assertSame(1, $query->count(), 'Left non-matching row behind');

        $query = $this->profile->query()->where(['user_id' => 3]);
        $this->assertSame(1, $query->count(), 'other records left behind');

        $user = new Entity(['id' => 3]);
        $this->assertTrue($association->cascadeDelete($user));
        $query = $this->profile->query()->where(['user_id' => 3]);
        $this->assertSame(0, $query->count(), 'Matching record was deleted.');
    }

    /**
     * Test cascading delete with has one.
     *
     * @return void
     */
    public function testCascadeDeleteCallbacks()
    {
        $config = [
            'dependent' => true,
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
            'conditions' => ['Profiles.is_active' => true],
            'cascadeCallbacks' => true,
        ];
        $association = new HasOne('Profiles', $config);

        $user = new Entity(['id' => 1]);
        $this->assertTrue($association->cascadeDelete($user));

        $query = $this->profile->query()->where(['user_id' => 1]);
        $this->assertSame(1, $query->count(), 'Left non-matching row behind');

        $query = $this->profile->query()->where(['user_id' => 3]);
        $this->assertSame(1, $query->count(), 'other records left behind');

        $user = new Entity(['id' => 3]);
        $this->assertTrue($association->cascadeDelete($user));
        $query = $this->profile->query()->where(['user_id' => 3]);
        $this->assertSame(0, $query->count(), 'Matching record was deleted.');
    }

    /**
     * Test cascading delete with a rule preventing deletion
     *
     * @return void
     */
    public function testCascadeDeleteCallbacksRuleFailure()
    {
        $config = [
            'dependent' => true,
            'sourceTable' => $this->user,
            'targetTable' => $this->profile,
            'cascadeCallbacks' => true,
        ];
        $association = new HasOne('Profiles', $config);
        $profiles = $association->getTarget();
        $profiles->getEventManager()->on('Model.buildRules', function ($event, $rules) {
            $rules->addDelete(function () {
                return false;
            });
        });

        $user = new Entity(['id' => 1]);
        $this->assertFalse($association->cascadeDelete($user));
        $matching = $profiles->find()
            ->where(['Profiles.user_id' => $user->id])
            ->all();
        $this->assertGreaterThan(0, count($matching));
    }
}
