<?php
declare(strict_types=1);
namespace Aen\CacheWorkshop;

use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
use Symfony\Contracts\Cache\ItemInterface;

final class Laboratory
{
    public const VERSION='7.4.23';
    private int $assertions=0;
    public function __construct(private readonly string $root) {}
    private function expect(bool $condition,string $message):void
    {
        $this->assertions++;
        if(!$condition)throw new \RuntimeException('Check failed: '.$message);
    }
    public static function newRun(string $project):string
    {
        $base=$project.'/runtime';
        if(is_link($base))throw new \RuntimeException('Redirected runtime is not allowed.');
        if(!is_dir($base)&&!mkdir($base,0700,true))throw new \RuntimeException('Cannot create project runtime.');
        $resolved=realpath($base);$expected=realpath($project).DIRECTORY_SEPARATOR.'runtime';
        if($resolved!==$expected)throw new \RuntimeException('Runtime must remain inside this project.');
        $run=$base.'/run-'.bin2hex(random_bytes(8));
        if(!mkdir($run,0700))throw new \RuntimeException('Cannot create new isolated run.');
        return $run;
    }
    private function pool(string $scene,string $namespace='demo'):FilesystemAdapter
    {
        return new FilesystemAdapter($namespace,0,$this->root.'/'.$scene);
    }
    private function files(string $scene):int
    {
        $path=$this->root.'/'.$scene;if(!is_dir($path))return 0;$count=0;
        $it=new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path,\FilesystemIterator::SKIP_DOTS));
        foreach($it as$file)if($file->isFile())$count++;
        return $count;
    }
    private function scene(string $id,string $title,string $purpose,array $events,array $checks,string $code):array
    {
        return compact('id','title','purpose','events','checks','code');
    }
    public function run():array
    {
        $scenes=[];
        $cache=$this->pool('hit');$calls=0;$events=[];
        $loader=function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(300);return ['id'=>42,'price'=>12500];};
        $a=$cache->get('product.42',$loader,0);$events[]=['action'=>'Первое get','result'=>$a,'callbackCalls'=>$calls];
        $b=$cache->get('product.42',$loader,0);$events[]=['action'=>'Повторное get','result'=>$b,'callbackCalls'=>$calls];
        $this->expect($a===$b&&$calls===1,'callback runs once for hit');
        $this->expect($cache->getItem('product.42')->isHit(),'PSR-6 item confirms hit');
        $scenes[]=$this->scene('hit','01 · Callback и попадание','Два чтения карточки товара обращаются к загрузчику только один раз.',$events,['calls'=>1,'reads'=>2,'isHit'=>true], <<<'PHP'
$cache = new FilesystemAdapter('catalog_v1', 0, $cacheDirectory);
$value = $cache->get('product.42', function (ItemInterface $item): array {
    $item->expiresAfter(300);
    return ['id' => 42, 'price' => 12500];
}, 0); // beta=0 makes this particular learning experiment deterministic
PHP);

        $cache=$this->pool('ttl');$calls=0;$events=[];
        $loader=function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(1);return ['revision'=>$calls];};
        $first=$cache->get('summary',$loader,0);$events[]=['action'=>'Записать с TTL 1 секунда','result'=>$first,'callbackCalls'=>$calls];
        sleep(2);$expired=!$cache->getItem('summary')->isHit();
        $second=$cache->get('summary',$loader,0);$events[]=['action'=>'Прочитать после реального ожидания 2 секунд','result'=>$second,'callbackCalls'=>$calls];
        $this->expect($expired&&$calls===2&&$second['revision']===2,'TTL actually expired');
        $scenes[]=$this->scene('ttl','02 · Истечение TTL','Короткое время жизни действительно истекает во время PHP-прогона.',$events,['expiredAfterWait'=>true,'calls'=>2,'waitSeconds'=>2], <<<'PHP'
$cache->get('summary', function (ItemInterface $item) {
    $item->expiresAfter(1);
    return loadSummary();
}, 0);
sleep(2); // Only for the small CLI experiment, never in a web request.
$miss = !$cache->getItem('summary')->isHit();
PHP);

        $cache=$this->pool('delete');$price=12500;$calls=0;$events=[];
        $loader=function(ItemInterface $item)use(&$price,&$calls):int{$calls++;$item->expiresAfter(300);return $price;};
        $events[]=['action'=>'Сохранить первоначальную цену','value'=>$cache->get('product.42',$loader,0)];
        $price=14000;$stale=$cache->get('product.42',$loader,0);$events[]=['action'=>'Источник изменён; get пока возвращает кеш','sourcePrice'=>$price,'value'=>$stale];
        $deleted=$cache->delete('product.42');$fresh=$cache->get('product.42',$loader,0);$events[]=['action'=>'delete и следующее get','value'=>$fresh,'callbackCalls'=>$calls];
        $this->expect($deleted&&$stale===12500&&$fresh===14000&&$calls===2,'delete makes next read refresh');
        $scenes[]=$this->scene('delete','03 · Обновление источника и delete','Изменение базы само по себе не обновляет уже сохранённую карточку.',$events,['stalePrice'=>12500,'freshPrice'=>14000,'calls'=>2], <<<'PHP'
// First commit the new price in the source of truth.
$repository->updatePrice(42, 14000);
$cache->delete('product.42');
// The following get() recomputes the value on demand.
PHP);

        $cache=new TagAwareAdapter($this->pool('tags'));$calls=['a'=>0,'b'=>0,'other'=>0];$events=[];
        $load=function(string $id,string $tag)use(&$calls):\Closure{return function(ItemInterface $item)use(&$calls,$id,$tag):array{$calls[$id]++;$item->expiresAfter(300);$item->tag($tag);return ['item'=>$id,'revision'=>$calls[$id]];};};
        foreach(['a','b','other']as$id)$cache->get($id,$load($id,$id==='other'?'category.9':'category.7'),0);
        $events[]=['action'=>'Загрузить два объекта category.7 и один category.9','callbackCalls'=>$calls];
        $invalidated=$cache->invalidateTags(['category.7']);
        foreach(['a','b','other']as$id)$cache->get($id,$load($id,$id==='other'?'category.9':'category.7'),0);
        $events[]=['action'=>'invalidateTags(category.7), затем повторное чтение','callbackCalls'=>$calls];
        $this->expect($invalidated&&$calls===['a'=>2,'b'=>2,'other'=>1],'tag invalidates only related items');
        $scenes[]=$this->scene('tags','04 · Связанные данные и теги','Одна категория обновляет карточки и список, не затрагивая другую категорию.',$events,['calls'=>$calls], <<<'PHP'
$cache = new TagAwareAdapter(new FilesystemAdapter('catalog_v1', 0, $cacheDirectory));
$cache->get('product.42', function (ItemInterface $item) {
    $item->expiresAfter(300);
    $item->tag(['product.42', 'category.7']);
    return loadProduct(42);
}, 0);
$cache->invalidateTags(['category.7']);
PHP);

        $one=$this->pool('namespace','tenant_a_v1');$two=$this->pool('namespace','tenant_b_v1');$events=[];
        $load=static function(string $tenant):\Closure{return static function(ItemInterface $item)use($tenant):array{$item->expiresAfter(300);return ['tenant'=>$tenant,'orders'=>$tenant==='A'?12:7];};};
        $a=$one->get('summary',$load('A'),0);$b=$two->get('summary',$load('B'),0);$one->clear();
        $events[]=['action'=>'Одинаковый ключ summary в двух namespace','tenantA'=>$a,'tenantB'=>$b];
        $events[]=['action'=>'clear только tenant_a_v1','tenantAHit'=>$one->getItem('summary')->isHit(),'tenantBHit'=>$two->getItem('summary')->isHit()];
        $this->expect(!$one->getItem('summary')->isHit()&&$two->getItem('summary')->isHit()&&$a!==$b,'namespace isolation');
        $scenes[]=$this->scene('namespace','05 · Пространства имён','Одинаковые ключи разных клиентов не смешиваются внутри отдельных пулов.',$events,['tenantAHit'=>false,'tenantBHit'=>true], <<<'PHP'
$tenantA = new FilesystemAdapter('tenant_a_v1', 0, $cacheDirectory);
$tenantB = new FilesystemAdapter('tenant_b_v1', 0, $cacheDirectory);
// Both may use key "summary". Namespace separation does not replace authorization.
$tenantA->clear(); // Does not clear tenant_b_v1.
PHP);

        $cache=$this->pool('negative');$calls=0;$events=[];
        $loader=function(ItemInterface $item)use(&$calls):?array{$calls++;$item->expiresAfter(30);return null;};
        $a=$cache->get('product.404',$loader,0);$b=$cache->get('product.404',$loader,0);$hit=$cache->getItem('product.404')->isHit();
        $events[]=['action'=>'Два запроса отсутствующего товара','first'=>$a,'second'=>$b,'callbackCalls'=>$calls,'isHit'=>$hit];
        $this->expect($a===null&&$b===null&&$calls===1&&$hit,'null is a cached result, not a miss');
        $scenes[]=$this->scene('negative','06 · Отрицательное кеширование','Коротко сохраняем результат «не найдено», чтобы не повторять бесполезный запрос.',$events,['cachedNull'=>true,'calls'=>1,'isHit'=>true], <<<'PHP'
$product = $cache->get('product.404', function (ItemInterface $item): ?array {
    $item->expiresAfter(30);
    return $repository->find(404); // null means a confirmed missing product.
}, 0);
// A network failure is different from a successful lookup returning null.
PHP);

        $cache=$this->pool('error');$calls=0;$events=[];$caught=false;
        try{$cache->get('rates',function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(60);throw new \RuntimeException('Учебный источник временно недоступен');},0);}
        catch(\RuntimeException $e){$caught=true;$events[]=['action'=>'Callback выбрасывает исключение','error'=>$e->getMessage(),'isHit'=>$cache->getItem('rates')->isHit()];}
        $value=$cache->get('rates',function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(60);return ['status'=>'ok','value'=>100];},0);
        $events[]=['action'=>'Следующая успешная попытка','result'=>$value,'callbackCalls'=>$calls];
        $this->expect($caught&&$calls===2&&$value['status']==='ok','exception is not cached as successful value');
        $scenes[]=$this->scene('error','07 · Ошибка загрузчика','Исключение не превращается автоматически в успешное кешированное значение.',$events,['errorWasCaught'=>true,'calls'=>2,'nextAttempt'=>'ok'], <<<'PHP'
try {
    $rates = $cache->get('rates', function (ItemInterface $item) {
        $item->expiresAfter(60);
        return $api->fetchRates(); // May throw; handle the failure deliberately.
    });
} catch (\RuntimeException $error) {
    // Report temporary unavailability; do not pretend this is a zero rate.
}
PHP);

        $cache=$this->pool('beta');$calls=0;$events=[];
        $loader=function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(300);return ['revision'=>$calls];};
        foreach([0.0,0.0,INF,0.0]as$beta){$value=$cache->get('popular',$loader,$beta);$events[]=['action'=>'get с beta '.(is_infinite($beta)?'INF':'0'),'result'=>$value,'callbackCalls'=>$calls];}
        $this->expect($calls===2&&$events[1]['result']['revision']===1&&$events[2]['result']['revision']===2,'beta zero hit and INF recompute');
        $scenes[]=$this->scene('beta','08 · Управляемый пересчёт beta','Сравниваем детерминированные крайние режимы; вероятностную конкуренцию здесь не измеряем.',$events,['calls'=>2,'testedBeta'=>['0','INF'],'concurrencyBenchmark'=>false], <<<'PHP'
$value = $cache->get('popular', $loader, 0);   // Disable probabilistic early recompute.
$fresh = $cache->get('popular', $loader, INF); // Force recompute on this get().
// Default beta=1 is probabilistic; test it under your actual workload.
PHP);

        $cache=$this->pool('prune');
        $short=$cache->getItem('short');$short->set('expires soon')->expiresAfter(1);$cache->save($short);
        $long=$cache->getItem('long');$long->set('keep')->expiresAfter(300);$cache->save($long);
        $before=$this->files('prune');sleep(2);$pruned=$cache->prune();$after=$this->files('prune');$events=[];
        $events[]=['action'=>'Записать короткую и длинную запись','files'=>$before];
        $events[]=['action'=>'После 2 секунд выполнить prune','success'=>$pruned,'files'=>$after,'longIsHit'=>$cache->getItem('long')->isHit()];
        $this->expect($pruned&&$before===2&&$after===1&&$cache->getItem('long')->isHit(),'prune removes expired file only');
        $scenes[]=$this->scene('prune','09 · Prune и физическая очистка','Истечение срока и освобождение файлов на диске — разные события.',$events,['beforeFiles'=>2,'afterFiles'=>1,'liveItemRetained'=>true], <<<'PHP'
$cache = new FilesystemAdapter('catalog_v1', 0, $cacheDirectory);
$success = $cache->prune(); // Remove expired entries, keep valid data.
// Run from a dedicated CLI job with write access to this specific cache directory.
PHP);
        return ['component'=>'symfony/cache','version'=>self::VERSION,'kind'=>'recorded-real-php-run','adapter'=>'FilesystemAdapter + TagAwareAdapter','scenarios'=>$scenes,'assertions'=>$this->assertions,'externalServicesUsed'=>false,'timingBenchmark'=>false];
    }
}
