<?php
declare(strict_types=1);
require dirname(__DIR__).'/vendor/autoload.php';
use Composer\InstalledVersions;
$root=dirname(__DIR__);$flockOnly=in_array('--flock-only',$argv,true);
if(!is_dir($root.'/public'))mkdir($root.'/public',0755,true);
$run=bin2hex(random_bytes(6));$dir=$root.'/.runtime/'.$run;mkdir($dir,0700,true);
function waitUntil(callable $condition,string $name): void { $end=microtime(true)+12;while(!$condition()){if(microtime(true)>$end)throw new RuntimeException('Timeout: '.$name);usleep(10000);clearstatcache();}}
function runCase(string $id,string $title,string $use,string $explain,string $store,array $workers,bool $kill=false):array {
 global $dir,$root,$run;
 $path=$dir.'/'.$id;mkdir($path,0700,true);$processes=[];$configs=[];
 try {
 foreach($workers as $name=>$custom) {
  $cfg=['id'=>$name,'store'=>$store,'directory'=>$path.'/locks','key'=>'aen-lock-b18:'.$run.':'.$id.':'.($custom['resource']??'report'),'displayKey'=>'aen:report:'.($custom['resource']??'42'),'ready'=>$path.'/'.$name.'.ready','go'=>$path.'/go','events'=>$path.'/'.$name.'.json',...$custom];
  if(isset($cfg['afterWorker'])){$cfg['after']=$path.'/'.$cfg['afterWorker'].'.acquired';unset($cfg['afterWorker']);}
  $cfg['acquiredFlag']=$path.'/'.$name.'.acquired';
  $configPath=$path.'/'.$name.'-config.json';file_put_contents($configPath,json_encode($cfg));$configs[$name]=$cfg;
  $descriptors=[0=>['pipe','r'],1=>['file',$path.'/'.$name.'.out','w'],2=>['file',$path.'/'.$name.'.err','w']];
  $p=proc_open([PHP_BINARY,$root.'/bin/worker.php',$configPath],$descriptors,$pipes,$root,null,['bypass_shell'=>true]);
  if(!is_resource($p))throw new RuntimeException('Cannot start child');fclose($pipes[0]);$processes[$name]=$p;
 }
 waitUntil(fn()=>count(glob($path.'/*.ready'))===count($workers),'all workers ready');
 $start=hrtime(true);file_put_contents($path.'/go','go');$controller=[];
 if($kill){waitUntil(fn()=>is_file($path.'/A.acquired'),'A acquired');usleep(100000);proc_terminate($processes['A']);$controller[]=['worker'=>'controller','kind'=>'terminated','message'=>'Владелец A принудительно завершён','at_ns'=>hrtime(true)];file_put_contents($path.'/killed','killed');}
 $exitCodes=[];foreach($processes as $name=>$p){$status=null;waitUntil(function()use($p,&$status){$status=proc_get_status($p);return !$status['running'];},$name.' exit');$exitCodes[$name]=$status['exitcode'];proc_close($p);}
 $events=$controller;
 foreach($configs as $cfg){$part=json_decode(file_get_contents($cfg['events']),true,512,JSON_THROW_ON_ERROR);array_push($events,...$part);}
 usort($events,fn($a,$b)=>$a['at_ns']<=>$b['at_ns']);
 foreach($events as &$e){$e['ms']=round(($e['at_ns']-$start)/1e6,1);unset($e['at_ns']);}unset($e);
 $result=['id'=>$id,'title'=>$title,'use'=>$use,'explain'=>$explain,'store'=>$store,'events'=>$events,'workerExitCodes'=>$exitCodes,'actualProcesses'=>count($workers)];
 validate($result);return $result;
 } finally {foreach($processes as $p)if(is_resource($p)){if(proc_get_status($p)['running'])proc_terminate($p);proc_close($p);}}
}
function validate(array $case):void {
 $events=$case['events'];$of=fn($k)=>array_values(array_filter($events,fn($e)=>$e['kind']===$k));
 if($of('fatal')||$of('unexpected'))throw new RuntimeException('Runtime failure '.$case['id']);
 $acq=$of('acquired');$denied=$of('denied');$released=$of('released');
 $ok=match($case['id']) {
  'contention'=>count($acq)===1&&count($denied)===1,
  'blocking'=>count($acq)===2&&$acq[1]['ms']>=$released[0]['ms']-15,
  'keys'=>count($acq)===2&&max(array_column($acq,'ms'))<min(array_column($released,'ms')),
  'finally'=>count($of('error'))===1&&count($acq)===2&&count($released)===2,
  'crash'=>count($acq)===2&&count($of('terminated'))===1&&count($released)===1,
  'ownership'=>count($acq)===1&&count($denied)===1&&$of('reentry')[0]['acquired']===true&&$of('other_object')[0]['acquired']===false&&$of('owner_check')[0]['isAcquired']===true,
  'ttl'=>count($acq)===2&&$of('expired')[0]['isAcquired']===false&&count($of('refresh_failed'))===1&&count($of('skipped_write'))===1,
  'refresh'=>count($acq)===2&&count($denied)===1&&count($of('refresh'))===4,
  default=>false
 };
 if(!$ok)throw new RuntimeException('Assertion failed '.$case['id']);
}
$cases=[];
$cases[]=runCase('contention','1. Два cron — одна операция','Экспорт отчёта не должен выполняться одновременно.','Оба PHP-процесса готовы до открытия общего барьера. Победитель получает Flock; второй немедленно пропускает работу.','flock',['A'=>[],'B'=>[]]);
$cases[]=runCase('blocking','2. Подождать освобождения','Последовательная обработка небольшого общего ресурса.','B вызывает acquire(true), пока A держит тот же ключ. Операции идут друг за другом. Длительность ожидания не является SLA.','flock',['A'=>['holdMs'=>500],'B'=>['afterWorker'=>'A','blocking'=>true,'holdMs'=>120]]);
$cases[]=runCase('keys','3. Разные ключи — параллельная работа','Не задерживать независимые отчёты клиентов.','Оба процесса используют одно хранилище, но разные имена ресурсов. Интервалы владения пересекаются.','flock',['A'=>['resource'=>'42'],'B'=>['resource'=>'77']]);
$cases[]=runCase('finally','4. Исключение и finally','Ошибка генерации файла не должна держать очередь.','A выбрасывает учебное исключение. finally освобождает ресурс; ожидающий B получает его и завершает свою работу.','flock',['A'=>['holdMs'=>180,'throw'=>true],'B'=>['afterWorker'=>'A','blocking'=>true,'holdMs'=>100]]);
$cases[]=runCase('crash','5. Завершение процесса-владельца','Понять, почему наличие lock-файла не означает занятый lock.','Контроллер завершает дочерний A после подтверждения acquire. ОС закрывает файловый дескриптор; B получает Flock. Здесь не удаляют lock-файл.','flock',['A'=>['killOwner'=>true],'B'=>['afterWorker'=>'A','blocking'=>true,'holdMs'=>100]],true);
if(!$flockOnly){
 $cases[]=runCase('ownership','6. Redis: общий ключ и владелец','Несколько worker должны видеть одно общее хранилище.','Повторный acquire того же объекта допустим. Другой объект и другой PHP-процесс не получают ресурс; чужой release не снимает владение.','redis',['A'=>['ownership'=>true,'holdMs'=>450],'B'=>['afterWorker'=>'A','delayMs'=>50]]);
 $cases[]=runCase('ttl','7. TTL истёк раньше работы','Длинная пауза worker может привести к потере аренды.','A держит аренду 350 мс, но спит 900 мс. B приходит после истечения TTL и получает ключ. Проснувшийся A обнаруживает потерю и отказывается от записи. Это не демонстрация fencing.','redis',['A'=>['ttl'=>0.35,'holdMs'=>900,'expired'=>true],'B'=>['afterWorker'=>'A','delayMs'=>500,'ttl'=>2.0,'holdMs'=>650]]);
 $cases[]=runCase('refresh','8. Продление живой аренды','Обрабатывать работу небольшими порциями и обновлять TTL.','A четыре раза продлевает аренду. B получает отказ во время работы. C приходит после release и успешно получает тот же ключ.','redis',['A'=>['ttl'=>0.5,'refresh'=>true],'B'=>['afterWorker'=>'A','delayMs'=>550],'C'=>['afterWorker'=>'A','delayMs'=>1050,'holdMs'=>100]]);
}
$hashes=[];foreach(['composer.json','composer.lock','src/Stores.php','bin/worker.php','bin/capture.php'] as $file)$hashes[$file]=hash_file('sha256',$root.'/'.$file);
$record=['schema'=>1,'capturedAt'=>gmdate('c'),'mechanism'=>'Real PHP child processes; Flock and RedisStore; browser replays recorded events only','runtime'=>['php'=>PHP_VERSION,'os'=>PHP_OS_FAMILY,'symfonyLock'=>InstalledVersions::getPrettyVersion('symfony/lock'),'predis'=>InstalledVersions::getPrettyVersion('predis/predis')],'scope'=>$flockOnly?'flock-only':'flock-and-redis','clock'=>'hrtime(true) on one host; displayed milliseconds relative to shared barrier; not a benchmark','sourceHashes'=>$hashes,'cases'=>$cases];
$out=$root.'/public/'.($flockOnly?'recording-flock.json':'recording.json');if(file_put_contents($out,json_encode($record,JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)."\n")===false)throw new RuntimeException('Cannot save recording');
echo count($cases).' scenarios passed; '.array_sum(array_map(fn($c)=>count($c['events']),$cases))." real events; $out\n";
