<?php

declare(strict_types=1);

/*
 * This file is part of php-cache organization.
 *
 * (c) 2015 Aaron Scherer <aequasi@gmail.com>, Tobias Nyholm <tobias.nyholm@gmail.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */

namespace Cache\IntegrationTests;

use Cache\TagInterop\TaggableCacheItemPoolInterface;
use PHPUnit\Framework\Attributes\After;
use PHPUnit\Framework\Attributes\Before;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
 * @author Tobias Nyholm <tobias.nyholm@gmail.com>
 */
abstract class TaggableCachePoolTest extends TestCase
{
    /** @var array<string, string> */
    protected array $skippedTests = [];

    protected TaggableCacheItemPoolInterface $cache;

    /**
     * @return TaggableCacheItemPoolInterface that is used in the tests
     */
    abstract public function createCachePool(): TaggableCacheItemPoolInterface;

    #[Before]
    public function setupService()
    {
        $this->cache = $this->createCachePool();
    }

    #[After]
    public function tearDownService()
    {
        if (isset($this->cache)) {
            $this->cache->clear();
        }
    }

    /** @return list<array{mixed}> */
    public static function invalidTags(): array
    {
        return array_merge(
            CachePoolTest::invalidKeys(),
            [
                [true],
                [false],
                [null],
                [2],
                [2.5],
                [new \stdClass()],
                [['array']],
            ]
        );
    }

    public function testMultipleTags()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $this->cache->save($this->cache->getItem('key1')->set('value')->setTags(['tag1', 'tag2']));
        $this->cache->save($this->cache->getItem('key2')->set('value')->setTags(['tag1', 'tag3']));
        $this->cache->save($this->cache->getItem('key3')->set('value')->setTags(['tag2', 'tag3']));
        $this->cache->save($this->cache->getItem('key4')->set('value')->setTags(['tag4', 'tag3']));

        $this->cache->invalidateTags(['tag1']);
        $this->assertFalse($this->cache->hasItem('key1'));
        $this->assertFalse($this->cache->hasItem('key2'));
        $this->assertTrue($this->cache->hasItem('key3'));
        $this->assertTrue($this->cache->hasItem('key4'));

        $this->cache->invalidateTags(['tag2']);
        $this->assertFalse($this->cache->hasItem('key1'));
        $this->assertFalse($this->cache->hasItem('key2'));
        $this->assertFalse($this->cache->hasItem('key3'));
        $this->assertTrue($this->cache->hasItem('key4'));
    }

    public function testPreviousTag()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $tags = $item->getPreviousTags();
        $this->assertCount(0, $tags);

        $item->setTags(['tag0']);
        $this->assertCount(0, $item->getPreviousTags());

        $this->cache->save($item);
        $this->assertCount(0, $item->getPreviousTags());

        $item = $this->cache->getItem('key');
        $this->assertCount(1, $item->getPreviousTags());
    }

    public function testPreviousTagDeferred()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $item->setTags(['tag0']);
        $this->assertCount(0, $item->getPreviousTags());

        $this->cache->saveDeferred($item);
        $this->assertCount(0, $item->getPreviousTags());

        $item = $this->cache->getItem('key');
        $this->assertCount(1, $item->getPreviousTags());
    }

    public function testPreviousTagsPreserveIdentityMap()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $item->setTags(['tag1', 'tag2']);
        $this->cache->save($item);

        $item = $this->cache->getItem('key');

        $this->assertSame(['tag1' => 'tag1', 'tag2' => 'tag2'], $item->getPreviousTags());
    }

    public function testTagAccessorWithEmptyTag()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $this->expectException('Psr\Cache\InvalidArgumentException');
        $item->setTags(['']);
    }

    #[DataProvider('invalidTags')]
    public function testTagAccessorWithInvalidTag(mixed $tag)
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $this->expectException('Psr\Cache\InvalidArgumentException');
        (new \ReflectionMethod($item, 'setTags'))->invoke($item, [$tag]);
    }

    public function testTagAccessorDuplicateTags()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $item->setTags(['tag', 'tag', 'tag']);
        $this->cache->save($item);
        $item = $this->cache->getItem('key');

        $this->assertCount(1, $item->getPreviousTags());
    }

    public function testNumericStringTagsRemainUsable()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        foreach (['0', '123', '-1'] as $tag) {
            $key = 'key_'.str_replace('-', 'minus_', $tag);
            $this->assertTrue($this->cache->save($this->cache->getItem($key)->set('value')->setTags([$tag])));
            $this->assertTrue($this->cache->hasItem($key));
            $this->assertTrue($this->cache->invalidateTag($tag));
            $this->assertFalse($this->cache->hasItem($key));
        }
    }

    public function testTagNamesOutsidePortableKeyAlphabetRemainUsable()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $tag = 'tag with spaces![]?';
        self::assertTrue($this->cache->save($this->cache->getItem('key_with_special_tag')->set('value')->setTags([$tag])));
        self::assertTrue($this->cache->hasItem('key_with_special_tag'));
        self::assertTrue($this->cache->invalidateTag($tag));
        self::assertFalse($this->cache->hasItem('key_with_special_tag'));
    }

    /**
     * The tag must be removed whenever we remove an item. If not, when creating a new item
     * with the same key will get the same tags.
     */
    public function testRemoveTagWhenItemIsRemoved()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $item->setTags(['tag1']);

        // Save the item and then delete it
        $this->cache->save($item);
        $this->cache->deleteItem('key');

        // Create a new item (same key) (no tags)
        $item = $this->cache->getItem('key')->set('value');
        $this->cache->save($item);

        // Clear the tag, The new item should not be cleared
        $this->cache->invalidateTags(['tag1']);
        $this->assertTrue($this->cache->hasItem('key'), 'Item key should be removed from the tag list when the item is removed');
    }

    public function testClearPool()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $item->setTags(['tag1']);
        $this->cache->save($item);

        // Clear the pool
        $this->cache->clear();

        // Create a new item (no tags)
        $item = $this->cache->getItem('key')->set('value');
        $this->cache->save($item);
        $this->cache->invalidateTags(['tag1']);

        $this->assertTrue($this->cache->hasItem('key'), 'Tags should be removed when the pool was cleared.');
    }

    public function testInvalidateTag()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $item->setTags(['tag1', 'tag2']);
        $this->cache->save($item);
        $item = $this->cache->getItem('key2')->set('value');
        $item->setTags(['tag1']);
        $this->cache->save($item);

        $this->cache->invalidateTag('tag2');
        $this->assertFalse($this->cache->hasItem('key'), 'Item should be cleared when tag is invalidated');
        $this->assertTrue($this->cache->hasItem('key2'), 'Item should be cleared when tag is invalidated');

        // Create a new item (no tags)
        $item = $this->cache->getItem('key')->set('value');
        $this->cache->save($item);
        $this->cache->invalidateTags(['tag2']);
        $this->assertTrue($this->cache->hasItem('key'), 'Item key list should be removed when clearing the tags');

        $this->cache->invalidateTags(['tag1']);
        $this->assertTrue($this->cache->hasItem('key'), 'Item key list should be removed when clearing the tags');
    }

    public function testInvalidateTags()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $item->setTags(['tag1', 'tag2']);
        $this->cache->save($item);
        $item = $this->cache->getItem('key2')->set('value');
        $item->setTags(['tag1']);
        $this->cache->save($item);

        $this->cache->invalidateTags(['tag1', 'tag2']);
        $this->assertFalse($this->cache->hasItem('key'), 'Item should be cleared when tag is invalidated');
        $this->assertFalse($this->cache->hasItem('key2'), 'Item should be cleared when tag is invalidated');

        // Create a new item (no tags)
        $item = $this->cache->getItem('key')->set('value');
        $this->cache->save($item);
        $this->cache->invalidateTags(['tag1']);

        $this->assertTrue($this->cache->hasItem('key'), 'Item k list should be removed when clearing the tags');
    }

    public function testInvalidateTagsValidatesEveryTagBeforeMutation()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        self::assertTrue($this->cache->save($this->cache->getItem('key')->set('value')->setTags(['valid'])));

        try {
            $this->cache->invalidateTags(['valid', '']);
            self::fail('invalidateTags accepted an invalid tag');
        } catch (\Psr\Cache\InvalidArgumentException) {
        }

        self::assertTrue($this->cache->hasItem('key'));
    }

    public function testInvalidateTagRemovesDeferredItem()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        self::assertTrue($this->cache->saveDeferred($this->cache->getItem('key')->set('value')->setTags(['tag'])));
        self::assertTrue($this->cache->invalidateTag('tag'));
        self::assertFalse($this->cache->hasItem('key'));
    }

    /**
     * When an item is overwritten we need to clear tags for original item.
     */
    public function testTagsAreCleanedOnSave()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $pool = $this->cache;
        $i = $pool->getItem('key')->set('value');
        $pool->save($i->setTags(['foo']));
        $i = $pool->getItem('key');
        $pool->save($i->setTags(['bar']));
        $pool->invalidateTags(['foo']);
        $this->assertTrue($pool->getItem('key')->isHit());
    }

    public function testTagsAreCleanedWhenSameItemIsSavedAgain()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $this->cache->save($item->setTags(['foo']));
        $this->cache->save($item->setTags(['bar']));

        $this->cache->invalidateTag('foo');

        $this->assertTrue($this->cache->getItem('key')->isHit());

        $this->cache->invalidateTag('bar');

        $this->assertFalse($this->cache->getItem('key')->isHit());
    }

    public function testExpiredSaveDoesNotLeaveTagIndex()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $expired = $this->cache->getItem('key')->set('expired')->setTags(['foo']);
        $expired->expiresAfter(-1);
        $this->cache->save($expired);

        $this->cache->save($this->cache->getItem('key')->set('replacement'));
        $this->cache->invalidateTag('foo');

        $this->assertTrue($this->cache->getItem('key')->isHit());
    }

    public function testTagsAreCleanedWhenSameItemIsSavedDeferredAgain()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $item = $this->cache->getItem('key')->set('value');
        $this->cache->saveDeferred($item->setTags(['foo']));
        $this->cache->commit();
        $this->cache->saveDeferred($item->setTags(['bar']));
        $this->cache->commit();

        $this->cache->invalidateTag('foo');

        $this->assertTrue($this->cache->getItem('key')->isHit());

        $this->cache->invalidateTag('bar');

        $this->assertFalse($this->cache->getItem('key')->isHit());
    }

    public function testExpiredDeferredSaveDoesNotLeaveTagIndex()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $expired = $this->cache->getItem('key')->set('expired')->setTags(['foo']);
        $expired->expiresAfter(-1);
        $this->cache->saveDeferred($expired);
        $this->cache->commit();

        $this->cache->save($this->cache->getItem('key')->set('replacement'));
        $this->cache->invalidateTag('foo');

        $this->assertTrue($this->cache->getItem('key')->isHit());
    }

    public function testImmediateSaveOverridesDeferredTags()
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $this->cache->save($this->cache->getItem('key')->set('first')->setTags(['foo']));
        $this->cache->saveDeferred($this->cache->getItem('key')->set('deferred')->setTags(['bar']));
        $this->cache->save($this->cache->getItem('key')->set('immediate')->setTags(['baz']));

        $this->cache->invalidateTag('foo');
        $this->cache->invalidateTag('bar');

        $this->assertSame('immediate', $this->cache->getItem('key')->get());

        $this->cache->commit();

        $this->assertSame('immediate', $this->cache->getItem('key')->get());

        $this->cache->invalidateTag('baz');

        $this->assertFalse($this->cache->getItem('key')->isHit());
    }
}
