<?php
declare(strict_types=1);

/**
 * BEdita, API-first content management framework
 * Copyright 2017 ChannelWeb Srl, Chialab Srl
 *
 * This file is part of BEdita: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published
 * by the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
 */

namespace BEdita\API\Test\TestCase\Controller\Model;

use BEdita\API\TestSuite\IntegrationTestCase;
use Cake\ORM\TableRegistry;
use Cake\Utility\Hash;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;

/**
 * @coversDefaultClass \BEdita\API\Controller\Model\RelationsController
 */
class RelationsControllerTest extends IntegrationTestCase
{
    use ArraySubsetAsserts;

    /**
     * Test index method.
     *
     * @return void
     * @covers ::index()
     * @covers ::initialize()
     * @covers ::prepareInclude()
     */
    public function testIndex()
    {
        $expected = [
            'links' => [
                'self' => 'http://api.example.com/model/relations',
                'first' => 'http://api.example.com/model/relations',
                'last' => 'http://api.example.com/model/relations',
                'prev' => null,
                'next' => null,
                'home' => 'http://api.example.com/home',
            ],
            'meta' => [
                'pagination' => [
                    'count' => 5,
                    'page' => 1,
                    'page_count' => 1,
                    'page_items' => 5,
                    'page_size' => 20,
                ],
            ],
            'data' => [
                [
                    'id' => '1',
                    'type' => 'relations',
                    'attributes' => [
                        'name' => 'test',
                        'label' => 'Test relation',
                        'inverse_name' => 'inverse_test',
                        'inverse_label' => 'Inverse test relation',
                        'description' => 'Sample description.',
                        'params' => null,
                    ],
                    'links' => [
                        'self' => 'http://api.example.com/model/relations/1',
                    ],
                    'relationships' => [
                        'left_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/1/relationships/left_object_types',
                                'related' => 'http://api.example.com/model/relations/1/left_object_types',
                            ],
                        ],
                        'right_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/1/relationships/right_object_types',
                                'related' => 'http://api.example.com/model/relations/1/right_object_types',
                            ],
                        ],
                    ],
                ],
                [
                    'id' => '2',
                    'type' => 'relations',
                    'attributes' => [
                        'name' => 'another_test',
                        'label' => 'Another test relation',
                        'inverse_name' => 'inverse_another_test',
                        'inverse_label' => 'Another inverse test relation',
                        'description' => 'Sample description /2.',
                        'params' => [
                            'type' => 'object',
                            'properties' => [
                                'name' => [
                                    'type' => 'string',
                                ],
                                'age' => [
                                    'type' => 'integer',
                                    'minimum' => 0,
                                ],
                            ],
                            'required' => ['name'],
                        ],
                    ],
                    'links' => [
                        'self' => 'http://api.example.com/model/relations/2',
                    ],
                    'relationships' => [
                        'left_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/2/relationships/left_object_types',
                                'related' => 'http://api.example.com/model/relations/2/left_object_types',
                            ],
                        ],
                        'right_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/2/relationships/right_object_types',
                                'related' => 'http://api.example.com/model/relations/2/right_object_types',
                            ],
                        ],
                    ],
                ],
                [
                    'id' => '3',
                    'type' => 'relations',
                    'attributes' => [
                        'name' => 'test_abstract',
                        'label' => 'Test relation involving abstract types',
                        'inverse_name' => 'inverse_test_abstract',
                        'inverse_label' => 'Inverse test relation involving abstract types',
                        'description' => 'Sample description.',
                        'params' => null,
                    ],
                    'links' => [
                        'self' => 'http://api.example.com/model/relations/3',
                    ],
                    'relationships' => [
                        'left_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/3/relationships/left_object_types',
                                'related' => 'http://api.example.com/model/relations/3/left_object_types',
                            ],
                        ],
                        'right_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/3/relationships/right_object_types',
                                'related' => 'http://api.example.com/model/relations/3/right_object_types',
                            ],
                        ],
                    ],
                ],
                [
                    'id' => '4',
                    'type' => 'relations',
                    'attributes' => [
                        'name' => 'test_simple',
                        'label' => 'Test relation involving simple parameters',
                        'inverse_name' => 'inverse_test_simple',
                        'inverse_label' => 'Inverse test relation involving simple parameters',
                        'description' => 'Sample description.',
                        'params' => [
                            'type' => 'object',
                            'properties' => [
                                'name' => [
                                    'type' => 'string',
                                ],
                                'age' => [
                                    'type' => 'integer',
                                    'minimum' => 0,
                                ],
                            ],
                        ],
                    ],
                    'links' => [
                        'self' => 'http://api.example.com/model/relations/4',
                    ],
                    'relationships' => [
                        'left_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/4/relationships/left_object_types',
                                'related' => 'http://api.example.com/model/relations/4/left_object_types',
                            ],
                        ],
                        'right_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/4/relationships/right_object_types',
                                'related' => 'http://api.example.com/model/relations/4/right_object_types',
                            ],
                        ],
                    ],
                ],
                [
                    'id' => '5',
                    'type' => 'relations',
                    'attributes' => [
                        'name' => 'test_defaults',
                        'label' => 'Test relation involving default parameters',
                        'inverse_name' => 'inverse_test_defaults',
                        'inverse_label' => 'Inverse test relation involving default parameters',
                        'description' => 'Sample description.',
                        'params' => [
                            'type' => 'object',
                            'properties' => [
                                'size' => [
                                    'type' => ['integer', 'null'],
                                    'default' => 5,
                                ],
                                'street' => [
                                    'type' => 'string',
                                    'default' => 'fighter',
                                ],
                                'color' => [
                                    'type' => ['string', 'null'],
                                    'default' => null,
                                ],
                            ],
                        ],
                    ],
                    'links' => [
                        'self' => 'http://api.example.com/model/relations/5',
                    ],
                    'relationships' => [
                        'left_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/5/relationships/left_object_types',
                                'related' => 'http://api.example.com/model/relations/5/left_object_types',
                            ],
                        ],
                        'right_object_types' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/relations/5/relationships/right_object_types',
                                'related' => 'http://api.example.com/model/relations/5/right_object_types',
                            ],
                        ],
                    ],
                ],
            ],
        ];

        $this->configRequestHeaders();
        $this->get('/model/relations');
        $result = json_decode((string)$this->_response->getBody(), true);

        $this->assertResponseCode(200);
        $this->assertContentType('application/vnd.api+json');
        static::assertEquals($expected, $result);
    }

    /**
     * Test index method.
     *
     * @return void
     * @covers ::resource()
     * @covers ::initialize()
     */
    public function testEmpty()
    {
        $expected = [
            'links' => [
                'self' => 'http://api.example.com/model/relations',
                'first' => 'http://api.example.com/model/relations',
                'last' => 'http://api.example.com/model/relations',
                'prev' => null,
                'next' => null,
                'home' => 'http://api.example.com/home',
            ],
            'meta' => [
                'pagination' => [
                    'count' => 0,
                    'page' => 1,
                    'page_count' => 1,
                    'page_items' => 0,
                    'page_size' => 20,
                ],
            ],
            'data' => [],
        ];

        TableRegistry::getTableLocator()->get('Relations')->deleteAll([]);

        $this->configRequestHeaders();
        $this->get('/model/relations');
        $result = json_decode((string)$this->_response->getBody(), true);

        $this->assertResponseCode(200);
        $this->assertContentType('application/vnd.api+json');
        $this->assertEquals($expected, $result);
    }

    /**
     * Test view method.
     *
     * @return void
     * @covers ::resource()
     * @covers ::initialize()
     */
    public function testSingle()
    {
        $expected = [
            'links' => [
                'self' => 'http://api.example.com/model/relations/1',
                'home' => 'http://api.example.com/home',
            ],
            'data' => [
                'id' => '1',
                'type' => 'relations',
                'attributes' => [
                    'name' => 'test',
                    'label' => 'Test relation',
                    'inverse_name' => 'inverse_test',
                    'inverse_label' => 'Inverse test relation',
                    'description' => 'Sample description.',
                    'params' => null,
                ],
                'relationships' => [
                    'left_object_types' => [
                        'links' => [
                            'self' => 'http://api.example.com/model/relations/1/relationships/left_object_types',
                            'related' => 'http://api.example.com/model/relations/1/left_object_types',
                        ],
                    ],
                    'right_object_types' => [
                        'links' => [
                            'self' => 'http://api.example.com/model/relations/1/relationships/right_object_types',
                            'related' => 'http://api.example.com/model/relations/1/right_object_types',
                        ],
                    ],
                ],
            ],
        ];

        $this->configRequestHeaders();
        $this->get('/model/relations/1');
        $result = json_decode((string)$this->_response->getBody(), true);

        $this->assertResponseCode(200);
        $this->assertContentType('application/vnd.api+json');
        $this->assertEquals($expected, $result);
    }

    /**
     * Test view method with `name` or `inverse_name` as argument.
     *
     * @return void
     * @covers ::getResourceId()
     */
    public function testSingleName()
    {
        $this->configRequestHeaders('GET', $this->getUserAuthHeader());
        $this->get('/model/relations/test');
        $this->assertResponseCode(200);

        $this->configRequestHeaders('GET', $this->getUserAuthHeader());
        $this->get('/model/relations/inverse_test');
        $this->assertResponseCode(200);
    }

    /**
     * Test view method.
     *
     * @return void
     * @covers ::resource()
     * @covers ::initialize()
     * @covers \BEdita\API\Error\ExceptionRenderer
     */
    public function testMissing()
    {
        $expected = [
            'links' => [
                'self' => 'http://api.example.com/model/relations/99',
                'home' => 'http://api.example.com/home',
            ],
            'error' => [
                'status' => '404',
            ],
        ];

        $this->configRequestHeaders();
        $this->get('/model/relations/99');
        $result = json_decode((string)$this->_response->getBody(), true);

        $this->assertResponseCode(404);
        $this->assertContentType('application/vnd.api+json');
        $this->assertArrayNotHasKey('data', $result);
        $this->assertArrayHasKey('links', $result);
        $this->assertArrayHasKey('error', $result);
        $this->assertEquals($expected['links'], $result['links']);
        $this->assertArraySubset($expected['error'], $result['error']);
        $this->assertArrayHasKey('title', $result['error']);
        $this->assertNotEmpty($result['error']['title']);
    }

    /**
     * Test add method.
     *
     * @return void
     * @covers ::index()
     * @covers ::initialize()
     * @covers ::resourceUrl()
     */
    public function testAdd()
    {
        $data = [
            'type' => 'relations',
            'attributes' => [
                'name' => 'shared',
                'label' => 'Shared',
                'inverse_name' => 'shared_by',
                'inverse_label' => 'Shared by',
                'description' => 'Shared relation',
                'params' => [
                    'test' => 'ok',
                ],
            ],
        ];

        $this->configRequestHeaders('POST', $this->getUserAuthHeader());
        $this->post('/model/relations', json_encode(compact('data')));

        $this->assertResponseCode(201);
        $this->assertContentType('application/vnd.api+json');

        $relation = TableRegistry::getTableLocator()->get('Relations')
            ->find()
            ->order(['id' => 'DESC'])
            ->first();

        $this->assertHeader('Location', 'http://api.example.com/model/relations/' . $relation->id);

        $expected = array_merge(['id' => $relation->id], $data['attributes'], [
            'params' => [
                'definitions' => new \stdClass(),
                '$schema' => 'http://json-schema.org/draft-06/schema#',
                'type' => 'object',
                'test' => 'ok',
            ],
        ]);
        static::assertJsonStringEqualsJsonString(json_encode($expected), json_encode($relation->toArray()));
    }

    /**
     * Test add method with invalid data.
     *
     * @return void
     * @covers ::index()
     * @covers ::initialize()
     */
    public function testAddInvalid()
    {
        $data = [
            'type' => 'relations',
            'attributes' => [
                'description' => 'Anonymous relation.',
            ],
        ];

        $Relations = TableRegistry::getTableLocator()->get('Relations');
        $count = $Relations->find()->count();

        $this->configRequestHeaders('POST', $this->getUserAuthHeader());
        $this->post('/model/relations', json_encode(compact('data')));

        $this->assertResponseCode(400);
        $this->assertContentType('application/vnd.api+json');
        $this->assertEquals($count, $Relations->find()->count());
    }

    /**
     * Test edit method.
     *
     * @return void
     * @covers ::resource()
     * @covers ::initialize()
     */
    public function testEdit()
    {
        $data = [
            'id' => '1',
            'type' => 'relations',
            'attributes' => [
                'label' => 'new label',
            ],
        ];

        $this->configRequestHeaders('PATCH', $this->getUserAuthHeader());
        $this->patch('/model/relations/1', json_encode(compact('data')));

        $this->assertResponseCode(200);
        $this->assertContentType('application/vnd.api+json');

        $Relations = TableRegistry::getTableLocator()->get('Relations');
        $entity = $Relations->get(1);
        $this->assertEquals('new label', $entity->get('label'));
    }

    /**
     * Test edit method with ID conflict.
     *
     * @return void
     * @covers ::resource()
     * @covers ::initialize()
     */
    public function testEditConflict()
    {
        $data = [
            'id' => '1',
            'type' => 'relations',
            'attributes' => [
                'label' => 'new label',
            ],
        ];

        $Relations = TableRegistry::getTableLocator()->get('Relations');
        $expected = $Relations->get(1)->get('label');

        $this->configRequestHeaders('PATCH', $this->getUserAuthHeader());
        $this->patch('/model/relations/2', json_encode(compact('data')));

        $this->assertResponseCode(409);
        $this->assertContentType('application/vnd.api+json');
        $this->assertEquals($expected, $Relations->get(1)->get('label'));
    }

    /**
     * Test delete method.
     *
     * @return void
     * @covers ::resource()
     * @covers ::initialize()
     */
    public function testDelete()
    {
        $this->configRequestHeaders('DELETE', $this->getUserAuthHeader());
        $this->delete('/model/relations/1');

        $this->assertResponseCode(204);
        $this->assertResponseEmpty();
        $this->assertFalse(TableRegistry::getTableLocator()->get('Relations')->exists(['id' => 1]));
    }

    /**
     * Test related method to list related object types.
     *
     * @return void
     * @covers ::initialize()
     * @covers ::related()
     * @covers ::findAssociation()
     * @covers ::prepareInclude()
     */
    public function testRelated()
    {
        $expected = [
            'links' => [
                'self' => 'http://api.example.com/model/relations/1/left_object_types',
                'first' => 'http://api.example.com/model/relations/1/left_object_types',
                'last' => 'http://api.example.com/model/relations/1/left_object_types',
                'prev' => null,
                'next' => null,
                'home' => 'http://api.example.com/home',
                'available' => 'http://api.example.com/model/object_types',
            ],
            'meta' => [
                'pagination' => [
                    'count' => 1,
                    'page' => 1,
                    'page_count' => 1,
                    'page_items' => 1,
                    'page_size' => 20,
                ],
            ],
            'data' => [
                [
                    'id' => '2',
                    'type' => 'object_types',
                    'attributes' => [
                        'singular' => 'document',
                        'name' => 'documents',
                        'description' => null,
                        'table' => 'BEdita/Core.Objects',
                        'associations' => ['Categories'],
                        'hidden' => null,
                        'is_abstract' => false,
                        'parent_name' => 'objects',
                        'enabled' => true,
                        'translation_rules' => null,
                        'is_translatable' => true,
                    ],
                    'meta' => [
                        'alias' => 'Documents',
                        'relations' => [
                            'test',
                            'test_simple',
                            'test_defaults',
                            'inverse_test',
                            'inverse_test_simple',
                            'inverse_test_defaults',
                        ],
                        'created' => '2017-11-10T09:27:23+00:00',
                        'modified' => '2017-11-10T09:27:23+00:00',
                        'core_type' => true,
                    ],
                    'links' => [
                        'self' => 'http://api.example.com/model/object_types/2',
                    ],
                    'relationships' => [
                        'left_relations' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/object_types/2/relationships/left_relations',
                                'related' => 'http://api.example.com/model/object_types/2/left_relations',
                            ],
                        ],
                        'right_relations' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/object_types/2/relationships/right_relations',
                                'related' => 'http://api.example.com/model/object_types/2/right_relations',
                            ],
                        ],
                        'parent' => [
                            'links' => [
                                'self' => 'http://api.example.com/model/object_types/2/relationships/parent',
                                'related' => 'http://api.example.com/model/object_types/2/parent',
                            ],
                        ],
                    ],
                ],
            ],
        ];

        $this->configRequestHeaders();
        $this->get('/model/relations/1/left_object_types');
        $result = json_decode((string)$this->_response->getBody(), true);

        /*
         * @todo To remove when fquffio :) resolves the inconsistency response.
         *       According with other endpoint responses "included" and "data" of "relationships"
         *       should be present only when the query string "include" is present
         */
        $result = Hash::remove($result, 'included');
        $result = Hash::remove($result, 'data.{n}.relationships.left_relations.data');
        $result = Hash::remove($result, 'data.{n}.relationships.right_relations.data');

        $this->assertResponseCode(200);
        $this->assertContentType('application/vnd.api+json');
        $this->assertEquals($expected, $result);
    }

    /**
     * Test adding object types to the left and to the right side of a relation
     *
     * @coversNothing
     * @return void
     */
    public function testPostLeftRightObjectTypes(): void
    {
        // `locations` and `events` to the left
        $payload = json_encode([
            'data' => [
                [
                    'type' => 'object_types',
                    'id' => 6,
                ],
                [
                    'type' => 'object_types',
                    'id' => 7,
                ],
            ],
        ]);
        $this->configRequestHeaders('POST', $this->getUserAuthHeader());
        $this->post('/model/relations/1/relationships/left_object_types', $payload);
        $this->assertResponseCode(200);

        // add only `users` to the right
        $payload = json_encode([
            'data' => [
                'type' => 'object_types',
                'id' => 4,
            ],
        ]);
        $this->configRequestHeaders('POST', $this->getUserAuthHeader());
        $this->post('/model/relations/1/relationships/right_object_types', $payload);
        $this->assertResponseCode(200);

        $this->configRequestHeaders();
        $this->get('/model/relations/1?include=left_object_types,right_object_types');
        $this->assertResponseCode(200);

        $result = json_decode((string)$this->_response->getBody(), true);
        $left = Hash::extract($result, 'data.relationships.left_object_types.data.{n}.id');
        $right = Hash::extract($result, 'data.relationships.right_object_types.data.{n}.id');

        static::assertTrue(in_array(6, $left));
        static::assertTrue(in_array(7, $left));
        static::assertTrue(in_array(4, $right));
    }

    /**
     * Test replacing object types to the left and to the right side of a relation
     *
     * @coversNothing
     * @return void
     */
    public function testPatchLeftRightObjectTypes(): void
    {
        // `locations` and `events` to the left
        $payload = json_encode([
            'data' => [
                [
                    'type' => 'object_types',
                    'id' => 6,
                ],
                [
                    'type' => 'object_types',
                    'id' => 7,
                ],
            ],
        ]);
        $this->configRequestHeaders('PATCH', $this->getUserAuthHeader());
        $this->patch('/model/relations/1/relationships/left_object_types', $payload);
        $this->assertResponseCode(200);

        // `users` to the right
        $payload = json_encode([
            'data' => [
                [
                    'type' => 'object_types',
                    'id' => 4,
                ],
            ],
        ]);
        $this->configRequestHeaders('PATCH', $this->getUserAuthHeader());
        $this->patch('/model/relations/1/relationships/right_object_types', $payload);
        $this->assertResponseCode(200);

        $this->configRequestHeaders();
        $this->get('/model/relations/1?include=left_object_types,right_object_types');
        $this->assertResponseCode(200);
        $result = json_decode((string)$this->_response->getBody(), true);
        $left = Hash::extract($result, 'data.relationships.left_object_types.data.{n}.id');
        $right = Hash::extract($result, 'data.relationships.right_object_types.data.{n}.id');

        sort($left);
        sort($right);
        static::assertEquals([6, 7], $left);
        static::assertEquals([4], $right);
    }
}
