<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\Cache\Tests\Adapter;

use Cache\IntegrationTests\CachePoolTest;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\PruneableInterface;
use Symfony\Contracts\Cache\CallbackInterface;
use Symfony\Contracts\Cache\NamespacedPoolInterface;

abstract class AdapterTestCase extends CachePoolTest
{
    protected function setUp(): void
    {
        if (!\array_key_exists('testPrune', $this->skippedTests) && !$this->createCachePool() instanceof PruneableInterface) {
            $this->skippedTests['testPrune'] = 'Not a pruneable cache pool.';
        }

        try {
            \assert(false === true, new \Exception());
            $this->skippedTests['testGetItemInvalidKeys'] =
            $this->skippedTests['testGetItemsInvalidKeys'] =
            $this->skippedTests['testHasItemInvalidKeys'] =
            $this->skippedTests['testDeleteItemInvalidKeys'] =
            $this->skippedTests['testDeleteItemsInvalidKeys'] = 'Keys are checked only when assert() is enabled.';
        } catch (\Exception $e) {
        }
    }

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

        $cache = $this->createCachePool();
        $cache->clear();

        $value = mt_rand();

        $this->assertSame($value, $cache->get('foo', function (CacheItem $item) use ($value) {
            $this->assertSame('foo', $item->getKey());

            return $value;
        }));

        $item = $cache->getItem('foo');
        $this->assertSame($value, $item->get());

        $isHit = true;
        $this->assertSame($value, $cache->get('foo', static function (CacheItem $item) use (&$isHit) { $isHit = false; }, 0));
        $this->assertTrue($isHit);

        $this->assertNull($cache->get('foo', function (CacheItem $item) use (&$isHit, $value) {
            $isHit = false;
            $this->assertTrue($item->isHit());
            $this->assertSame($value, $item->get());
        }, \INF));
        $this->assertFalse($isHit);

        $this->assertSame($value, $cache->get('bar', new class($value) implements CallbackInterface {
            private int $value;

            public function __construct(int $value)
            {
                $this->value = $value;
            }

            public function __invoke(CacheItemInterface $item, bool &$save): mixed
            {
                Assert::assertSame('bar', $item->getKey());

                return $this->value;
            }
        }));
    }

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

        $cache = $this->createCachePool(0, __FUNCTION__);

        $v = $cache->get('k1', static function () use (&$counter, $cache) {
            $cache->get('k2', static function () use (&$counter) { return ++$counter; });
            $v = $cache->get('k2', static function () use (&$counter) { return ++$counter; }); // ensure the callback is called once

            return $v;
        });

        $this->assertSame(1, $counter);
        $this->assertSame(1, $v);
        $this->assertSame(1, $cache->get('k2', static fn () => 2));
    }

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

        $cache = $this->createCachePool(0, __FUNCTION__);

        $v1 = $cache->get('some-key', static function ($item, &$save) {
            $save = false;

            return 1;
        });
        $this->assertSame($v1, 1);

        $v2 = $cache->get('some-key', static fn () => 2);
        $this->assertSame($v2, 2, 'First value was cached and should not have been');

        $v3 = $cache->get('some-key', function () {
            $this->fail('Value should have come from cache');
        });
        $this->assertSame($v3, 2);
    }

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

        $cache = $this->createCachePool(0, __FUNCTION__);

        $cache->deleteItem('foo');
        $cache->get('foo', static function ($item) {
            $item->expiresAfter(10);
            usleep(999000);

            return 'bar';
        });

        $item = $cache->getItem('foo');

        $metadata = $item->getMetadata();
        $this->assertArrayHasKey(CacheItem::METADATA_CTIME, $metadata);
        $this->assertEqualsWithDelta(999, $metadata[CacheItem::METADATA_CTIME], 150);
        $this->assertArrayHasKey(CacheItem::METADATA_EXPIRY, $metadata);
        $this->assertEqualsWithDelta(9 + time(), $metadata[CacheItem::METADATA_EXPIRY], 1);
    }

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

        $cache = $this->createCachePool(2);

        $item = $cache->getItem('key.dlt');
        $item->set('value');
        $cache->save($item);
        sleep(1);

        $item = $cache->getItem('key.dlt');
        $this->assertTrue($item->isHit());

        sleep(2);
        $item = $cache->getItem('key.dlt');
        $this->assertFalse($item->isHit());
    }

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

        $cache = $this->createCachePool();
        $cache->save($cache->getItem('k1')->set('v1')->expiresAfter(2));
        $cache->save($cache->getItem('k2')->set('v2')->expiresAfter(366 * 86400));

        sleep(3);
        $item = $cache->getItem('k1');
        $this->assertFalse($item->isHit());
        $this->assertNull($item->get(), "Item's value must be null when isHit() is false.");

        $item = $cache->getItem('k2');
        $this->assertTrue($item->isHit());
        $this->assertSame('v2', $item->get());
    }

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

        $cache = $this->createCachePool();

        $item = $cache->getItem('foo');
        $cache->save($item->set(new NotUnserializable()));

        $item = $cache->getItem('foo');
        $this->assertFalse($item->isHit());

        foreach ($cache->getItems(['foo']) as $item) {
        }
        $cache->save($item->set(new NotUnserializable()));

        foreach ($cache->getItems(['foo']) as $item) {
        }
        $this->assertFalse($item->isHit());
    }

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

        if (!method_exists($this, 'isPruned')) {
            $this->fail('Test classes for pruneable caches must implement `isPruned($cache, $name)` method.');
        }

        /** @var PruneableInterface|CacheItemPoolInterface $cache */
        $cache = $this->createCachePool();

        $doSet = static function ($name, $value, ?\DateInterval $expiresAfter = null) use ($cache) {
            $item = $cache->getItem($name);
            $item->set($value);

            if ($expiresAfter) {
                $item->expiresAfter($expiresAfter);
            }

            $cache->save($item);
        };

        $doSet('foo', 'foo-val', new \DateInterval('PT05S'));
        $doSet('bar', 'bar-val', new \DateInterval('PT10S'));
        $doSet('baz', 'baz-val', new \DateInterval('PT15S'));
        $doSet('qux', 'qux-val', new \DateInterval('PT20S'));

        sleep(30);
        $this->assertTrue($cache->prune());
        $this->assertTrue($this->isPruned($cache, 'foo'));
        $this->assertTrue($this->isPruned($cache, 'bar'));
        $this->assertTrue($this->isPruned($cache, 'baz'));
        $this->assertTrue($this->isPruned($cache, 'qux'));

        $doSet('foo', 'foo-val');
        $doSet('bar', 'bar-val', new \DateInterval('PT20S'));
        $doSet('baz', 'baz-val', new \DateInterval('PT40S'));
        $doSet('qux', 'qux-val', new \DateInterval('PT80S'));

        $this->assertTrue($cache->prune());
        $this->assertFalse($this->isPruned($cache, 'foo'));
        $this->assertFalse($this->isPruned($cache, 'bar'));
        $this->assertFalse($this->isPruned($cache, 'baz'));
        $this->assertFalse($this->isPruned($cache, 'qux'));

        sleep(30);
        $this->assertTrue($cache->prune());
        $this->assertFalse($this->isPruned($cache, 'foo'));
        $this->assertTrue($this->isPruned($cache, 'bar'));
        $this->assertFalse($this->isPruned($cache, 'baz'));
        $this->assertFalse($this->isPruned($cache, 'qux'));

        sleep(30);
        $this->assertTrue($cache->prune());
        $this->assertFalse($this->isPruned($cache, 'foo'));
        $this->assertTrue($this->isPruned($cache, 'baz'));
        $this->assertFalse($this->isPruned($cache, 'qux'));

        sleep(30);
        $this->assertTrue($cache->prune());
        $this->assertFalse($this->isPruned($cache, 'foo'));
        $this->assertTrue($this->isPruned($cache, 'qux'));
    }

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

        $cache = $this->createCachePool(0, __FUNCTION__);
        $cache->clear();

        $item = $cache->getItem('foobar');
        $cache->save($item->set(1));

        $item = $cache->getItem('barfoo');
        $cache->save($item->set(2));

        $cache->clear('foo');
        $this->assertFalse($cache->hasItem('foobar'));
        $this->assertTrue($cache->hasItem('barfoo'));
    }

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

        $cache = $this->createCachePool(0, __FUNCTION__);
        $cache->clear();

        $cache->save($cache->getItem('DC2_REGION_product_region_key')->set(1));
        $cache->save($cache->getItem('other_key')->set(2));

        $this->assertTrue($cache->clear('DC2_REGION_'));
        $this->assertFalse($cache->hasItem('DC2_REGION_product_region_key'));
        $this->assertTrue($cache->hasItem('other_key'));
    }

    #[DataProvider('provideInvalidPrefixes')]
    public function testClearWithInvalidPrefix(string $prefix)
    {
        if (isset($this->skippedTests[__FUNCTION__])) {
            $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
        }

        $cache = $this->createCachePool(0, __FUNCTION__);
        $cache->clear();

        $cache->save($cache->getItem('foobar')->set(1));

        $this->assertFalse($cache->clear($prefix));
        $this->assertTrue($cache->hasItem('foobar'));
    }

    public static function provideInvalidPrefixes(): iterable
    {
        yield 'single quote' => ["foo' OR 1=1; --"];
        yield 'percent wildcard' => ['foo%'];
        yield 'space' => ['foo bar'];
        yield 'null byte' => ["foo\0bar"];
        yield 'backslash' => ['foo\\bar'];
        yield 'slash' => ['foo/bar'];
    }

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

        $cache = $this->createCachePool(0, __FUNCTION__);
        $cache->clear();

        $item = $cache->getItem('foobar');

        // it should be an array containing only one element
        // with key having a strlen of 10.
        $weirdDataMatchingMedatataWrappedValue = [
            1234567890 => [
                1,
            ],
        ];

        $cache->save($item->set($weirdDataMatchingMedatataWrappedValue));

        $this->assertTrue($cache->hasItem('foobar'));
    }

    public function testNullByteInKey()
    {
        $cache = $this->createCachePool(0, __FUNCTION__);

        $cache->save($cache->getItem("a\0b")->set(123));

        $this->assertSame(123, $cache->getItem("a\0b")->get());
    }

    public function testNumericKeysWorkAfterMemoryLeakPrevention()
    {
        $cache = $this->createCachePool(0, __FUNCTION__);

        for ($i = 0; $i < 1001; ++$i) {
            $cacheItem = $cache->getItem((string) $i);
            $cacheItem->set('value-'.$i);
            $cache->save($cacheItem);
        }

        $this->assertEquals('value-50', $cache->getItem((string) 50)->get());
    }

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

        $cache = $this->createCachePool(0, __FUNCTION__);

        $item = $cache->getItem('foo');
        $this->assertTrue($cache->save($item->set('bar')));
        $this->assertTrue($cache->hasItem('foo'));

        $item->set(static fn () => null);
        $this->assertFalse($cache->save($item));
        $this->assertSame('bar', $cache->getItem('foo')->get());
    }

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

        $cache = $this->createCachePool(0, __FUNCTION__);

        $this->assertInstanceOf(NamespacedPoolInterface::class, $cache);

        $derived = $cache->withSubNamespace('derived');

        $item = $derived->getItem('foo');
        $derived->save($item->set('Foo'));

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

        $item = $cache->getItem('bar');
        $cache->save($item->set('Bar'));

        $this->assertFalse($derived->getItem('bar')->isHit());
        $this->assertTrue($cache->getItem('bar')->isHit());

        $derived = $cache->withSubNamespace('derived');
        $this->assertTrue($derived->getItem('foo')->isHit());
    }
}

class NotUnserializable
{
    public function __unserialize(array $data): void
    {
        throw new \Exception(__CLASS__);
    }
}
