<?php

namespace BlueM;

use BlueM\Helper\IterableObjectFactory;
use BlueM\Tree\Exception\InvalidParentException;
use BlueM\Tree\Exception\MissingNodeInvalidParentException;
use BlueM\Tree\Node;
use BlueM\Tree\Options;
use BlueM\Tree\Serializer\HierarchicalTreeJsonSerializer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\Attributes\Ticket;
use PHPUnit\Framework\TestCase;

class TreeTest extends TestCase
{
    #[Test]
    #[TestDox('Constructor args: Root nodes’ parent ID can be defined as null')]
    public function constructorArgsNullAsRootNodeParentId(): void
    {
        $data = [
            ['id' => 1, 'parent' => null, 'name' => 'Root node'],
            ['id' => 2, 'parent' => 1, 'name' => 'Child'],
            ['id' => 3, 'parent' => 2, 'name' => 'Grandchild'],
            ['id' => 4, 'parent' => null, 'name' => 'Root'],
        ];

        $sut = new Tree($data, new Options(rootId: null));

        $nodes = $sut->getNodes();
        static::assertCount(4, $nodes);
        static::assertSame(1, $nodes[0]->getId());
        static::assertSame(2, $nodes[1]->getId());
        static::assertSame(3, $nodes[2]->getId());
        static::assertSame(4, $nodes[3]->getId());

        static::assertSame($nodes[0], $nodes[1]->getParent());
    }

    #[Test]
    #[TestDox('Constructor args: The root node’s ID can be defined as null while there is a node with ID 0')]
    public function constructorArgsNullAsRootNodeIdPlusNodeWithId0(): void
    {
        $data = [
            ['id' => 1, 'parent' => null, 'name' => 'Top-level node'],
            ['id' => 0, 'parent' => 1, 'name' => 'Child'],
            ['id' => 3, 'parent' => 0, 'name' => 'Grandchild'],
        ];

        $sut = new Tree($data, new Options(rootId: null));

        $nodes = $sut->getNodes();
        static::assertCount(3, $nodes);
        static::assertSame(1, $nodes[0]->getId());
        static::assertSame(0, $nodes[1]->getId());
        static::assertSame(3, $nodes[2]->getId());

        static::assertSame($nodes[0], $nodes[1]->getParent());
        static::assertNull($nodes[0]->getParent());
    }

    #[Test]
    #[TestDox('Constructor args: Name of fields for id and parent id in the input data can be changed')]
    public function constructorArgsChangeFieldNames(): void
    {
        $data = self::dummyDataWithStringKeys('id_node', 'id_parent');

        $sut = new Tree($data, new Options(rootId: '', idFieldName: 'id_node', parentIdFieldName: 'id_parent'));

        $nodes = $sut->getRootNodes();

        $expectedOrder = ['building', 'vehicle'];

        for ($i = 0, $ii = \count($nodes); $i < $ii; ++$i) {
            static::assertInstanceOf(Node::class, $nodes[$i]);
            static::assertSame($expectedOrder[$i], $nodes[$i]->getId());
        }
    }

    #[Test]
    #[TestDox('Constructor args: the JSON serializer can be changed')]
    public function constructorArgsChangeJsonSerializer(): void
    {
        $serializer = new HierarchicalTreeJsonSerializer();
        $sut = new Tree([], new Options(jsonSerializer: $serializer));
        $jsonSerializerProperty = new \ReflectionProperty($sut, 'jsonSerializer');
        static::assertSame($serializer, $jsonSerializerProperty->getValue($sut));
    }

    #[Test]
    #[TestDox('The JSON serializer can be changed by calling a method')]
    public function changeJsonSerializer(): void
    {
        $serializer = new HierarchicalTreeJsonSerializer();
        $sut = new Tree([]);
        $jsonSerializerProperty = new \ReflectionProperty($sut, 'jsonSerializer');
        static::assertNotSame($serializer, $jsonSerializerProperty->getValue($sut));

        $sut->setJsonSerializer($serializer);
        static::assertSame($serializer, $jsonSerializerProperty->getValue($sut));
    }

    #[Test]
    #[TestDox('Build: The tree can be rebuilt from new data')]
    public function buildRebuildWithNewData(): void
    {
        $data = self::dummyDataWithNumericKeys();

        $sut = new Tree($data);
        $originalData = json_encode($sut);

        for ($i = 0; $i < 3; ++$i) {
            $sut->rebuildWithData($data);
            static::assertSame($originalData, json_encode($sut));
        }
    }

    #[Test]
    #[TestDox('Build: A tree can be created from an Iterable')]
    public function buildFromFromAnIterable(): void
    {
        $sut = new Tree(
            IterableObjectFactory::makeIterableInstance(
                [
                    ['id' => 1, 'parent' => 0],
                    ['id' => 2, 'parent' => 0],
                    ['id' => 3, 'parent' => 2],
                    ['id' => 4, 'parent' => 0],
                ]
            )
        );
        static::assertSame('[{"id":1,"parent":0},{"id":2,"parent":0},{"id":3,"parent":2},{"id":4,"parent":0}]', json_encode($sut));
    }

    #[Test]
    #[TestDox('Build: A tree can be created from an array of objects implementing Iterator')]
    public function buildFromAnArrayOfIterators(): void
    {
        $sut = new Tree([
            IterableObjectFactory::makeIterableInstance(['id' => 1, 'parent' => 0, 'title' => 'A']),
            IterableObjectFactory::makeIterableInstance(['id' => 2, 'parent' => 0, 'title' => 'B']),
            IterableObjectFactory::makeIterableInstance(['id' => 3, 'parent' => 2, 'title' => 'B-1']),
            IterableObjectFactory::makeIterableInstance(['id' => 4, 'parent' => 0, 'title' => 'D']),
        ]);

        static::assertSame(
            '[{"id":1,"parent":0,"title":"A"},{"id":2,"parent":0,"title":"B"},{"id":3,"parent":2,"title":"B-1"},{"id":4,"parent":0,"title":"D"}]',
            json_encode($sut)
        );
    }

    #[Test]
    #[TestDox('Build: The tree can be serialized to a json representation from which a tree with the same data can be built when decoded')]
    public function rebuildFromJSONSerialization(): void
    {
        $data = self::dummyDataWithNumericKeys();

        $sut1 = new Tree($data);
        $sut1Json = json_encode($sut1);
        $sut1JsonDecoded = json_decode($sut1Json, true);

        static::assertCount(\count($data), $sut1JsonDecoded);
        foreach ($data as $nodeData) {
            ksort($nodeData);
            // Note: static::assertContains() fails
            /* @noinspection PhpUnitTestsInspection */
            static::assertTrue(in_array($nodeData, $data));
        }

        $sut2 = new Tree($sut1JsonDecoded);
        $sut2Json = json_encode($sut2);

        static::assertSame($sut1Json, $sut2Json);
    }

    #[Test]
    #[TestDox('Build: a custom warning callback can be used, which is called with an exception and the tree instance as arguments')]
    #[Ticket('https://github.com/BlueM/Tree/issues/26')]
    public function aCustomBuildWarningCallbackCanBeSpecifiedWhichIsCalledWithNodeAndParentIdAsArgument(): void
    {
        $invocationCount = 0;
        $treeArg = null;
        $buildwarningcallback = function (MissingNodeInvalidParentException $exception, Tree $tree, Node $node, mixed $parentId) use (&$invocationCount, &$treeArg) {
            ++$invocationCount;
            static::assertSame('Node with ID 2 points to non-existent parent with empty parent ID', $exception->getMessage());
            $treeArg = $tree;
            static::assertSame(2, $node->getId());
            static::assertSame('', $parentId);
        };

        $tree = new Tree(
            [
                ['id' => 1, 'parent' => 0],
                ['id' => 2, 'parent' => ''],
            ],
            new Options(buildWarningCallback: $buildwarningcallback),
        );

        static::assertSame(1, $invocationCount);
        static::assertSame($tree, $treeArg);
    }

    #[Test]
    #[TestDox('Nodes: The root nodes can be retrieved')]
    public function nodesGetRootNodes(): void
    {
        $data = self::dummyDataWithNumericKeys();
        $sut = new Tree($data);

        $nodes = $sut->getRootNodes();
        static::assertCount(5, $nodes);

        $expectedOrder = [5, 3, 4, 6, 1];

        for ($i = 0, $ii = \count($nodes); $i < $ii; ++$i) {
            static::assertInstanceOf(Node::class, $nodes[$i]);
            static::assertSame($expectedOrder[$i], $nodes[$i]->getId());
        }
    }

    #[Test]
    #[TestDox('Nodes: All nodes can be retrieved')]
    public function nodesGetAllNodes(): void
    {
        $data = self::dummyDataWithNumericKeys();
        $sut = new Tree($data);

        $nodes = $sut->getNodes();
        static::assertCount(\count($data), $nodes);

        $expectedOrder = [5, 3, 4, 6, 1, 7, 15, 11, 21, 27, 12, 10, 20];

        for ($i = 0, $ii = \count($nodes); $i < $ii; ++$i) {
            static::assertInstanceOf(Node::class, $nodes[$i]);
            static::assertSame($expectedOrder[$i], $nodes[$i]->getId());
        }
    }

    #[Test]
    #[TestDox('Nodes: A node can be accessed by its integer id')]
    public function nodeGetByIntId(): void
    {
        $data = self::dummyDataWithNumericKeys();
        $sut = new Tree($data);
        $node = $sut->getNodeById(20);
        static::assertEquals(20, $node->getId());
        static::assertEquals($node, $sut->getNodeById('20'));
    }

    #[Test]
    #[TestDox('Nodes: A node can be accessed by its string id')]
    public function nodeGetByStringId(): void
    {
        $data = self::dummyDataWithStringKeys();
        $sut = new Tree($data, new Options(rootId: ''));
        $node = $sut->getNodeById('library');
        static::assertEquals('library', $node->getId());
    }

    #[Test]
    #[TestDox('Nodes: Trying to get a node by its id throws an exception if the id is invalid')]
    public function nodeGetByInvalidId(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Invalid node primary key 999');

        $data = self::dummyDataWithNumericKeys();
        $sut = new Tree($data);
        $sut->getNodeById(999);
    }

    #[Test]
    #[TestDox('Nodes: When getNodeById() is called with root nodes’ parent value, an exception will be thrown')]
    #[Ticket('https://github.com/BlueM/Tree/issues/41')]
    public function nodeGetByRootNodeParentValue(): void
    {
        $data = [];
        $sut = new Tree($data);

        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Invalid node primary key 0');
        $sut->getNodeById(0);
    }

    #[Test]
    #[TestDox('Nodes: A node can be accessed by its value path')]
    public function nodeGetByValuePath(): void
    {
        $data = self::dummyDataWithNumericKeys();
        $sut = new Tree($data);
        static::assertEquals(
            $sut->getNodeById(11),
            $sut->getNodeByValuePath('name', ['Europe', 'Germany', 'Hamburg'])
        );
    }

    #[Test]
    #[TestDox('Nodes: Trying to get a node by its value path returns null if no node matches')]
    public function nodeGetByUnresolvableValuePath(): void
    {
        $data = self::dummyDataWithNumericKeys();
        $sut = new Tree($data);
        static::assertEquals(
            null,
            $sut->getNodeByValuePath('name', ['Europe', 'Germany', 'Frankfurt'])
        );
    }

    #[Test]
    public function inScalarContextTheTreeIsReturnedAsAString(): void
    {
        $data = self::dummyDataWithNumericKeys();
        $sut = new Tree($data);
        $actual = (string) $sut;
        $expected = <<<'EXPECTED'
- 5
- 3
- 4
- 6
- 1
  - 7
    - 15
    - 11
      - 21
      - 27
    - 12
  - 10
    - 20
EXPECTED;

        static::assertEquals($expected, $actual);
    }

    #[Test]
    public function anExceptionIsThrownWhenAnInvalidParentIdIsReferenced(): void
    {
        $this->expectException(InvalidParentException::class);
        $this->expectExceptionMessage('123 points to non-existent parent with ID 456');

        new Tree([
            ['id' => 123, 'parent' => 456],
        ]);
    }

    #[Test]
    public function anExceptionIsThrownIfTheBuildWarningCallbackOptionIsNotACallable(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('$buildWarningCallback must be a callable');

        new Tree(
            [
                ['id' => 1, 'parent' => 0],
                ['id' => 2, 'parent' => ''],
            ],
            new Options(buildWarningCallback: 'Will cause exception'),
        );
    }

    #[Test]
    public function anExceptionIsThrownWhenANodeWouldBeItsOwnParent(): void
    {
        $this->expectException(InvalidParentException::class);
        $this->expectExceptionMessage('678 references its own ID as parent');

        new Tree([
            ['id' => 123, 'parent' => 0],
            ['id' => 678, 'parent' => 678],
        ]);
    }

    #[Test]
    public function anExceptionIsThrownWhenANodeWouldBeItsOwnParentWhenOwnIdAndParentIdHaveDifferentTypes(): void
    {
        $this->expectException(InvalidParentException::class);
        $this->expectExceptionMessage('references its own ID as parent');

        new Tree([
            ['id' => '5', 'parent' => 5],
        ]);
    }

    #[Test]
    public function whenMixingNumericAndStringIdsNoExceptionIsThrownDueToImplicitTypecasting(): void
    {
        new Tree([
            ['id' => 'foo', 'parent' => 0],
        ]);
        static::assertTrue(true); // Just to make PHPUnit happy
    }

    /**
     * @return array<array<string, string>>
     */
    private static function dummyDataWithNumericKeys(): array
    {
        return [
            ['id' => 21, 'name' => 'Altona', 'parent' => 11],
            ['id' => 5, 'name' => 'Africa', 'parent' => 0],
            ['id' => 3, 'name' => 'America', 'parent' => 0],
            ['id' => 4, 'name' => 'Asia', 'parent' => 0],
            ['id' => 6, 'name' => 'Australia', 'parent' => 0],
            ['id' => 15, 'name' => 'Berlin', 'parent' => 7],
            ['id' => 27, 'name' => 'Eimsbüttel', 'parent' => 11],
            ['id' => 1, 'name' => 'Europe', 'parent' => 0],
            ['id' => 7, 'name' => 'Germany', 'parent' => 1],
            ['id' => 11, 'name' => 'Hamburg', 'parent' => 7],
            ['id' => 20, 'name' => 'Lisbon', 'parent' => 10],
            ['id' => 12, 'name' => 'Munich', 'parent' => 7],
            ['id' => 10, 'name' => 'Portugal', 'parent' => 1],
        ];
    }

    /**
     * @return array<array<string, string>>
     */
    private static function dummyDataWithStringKeys(string $nodeIdPropertyName = 'id', string $parentIdPropertyName = 'parent'): array
    {
        return [
            [$nodeIdPropertyName => 'bicycle', $parentIdPropertyName => 'vehicle'],
            [$nodeIdPropertyName => 'building', $parentIdPropertyName => ''],
            [$nodeIdPropertyName => 'car', $parentIdPropertyName => 'vehicle'],
            [$nodeIdPropertyName => 'library', $parentIdPropertyName => 'building'],
            [$nodeIdPropertyName => 'primary-school', $parentIdPropertyName => 'school'],
            [$nodeIdPropertyName => 'school', $parentIdPropertyName => 'building'],
            [$nodeIdPropertyName => 'vehicle', $parentIdPropertyName => ''],
        ];
    }
}
