<?php
declare(strict_types=1);


function u_lower(string $s): string { return function_exists('mb_strtolower')?mb_strtolower($s,'UTF-8'):strtolower($s); }
function u_pos(string $h,string $n){ return function_exists('mb_strpos')?mb_strpos($h,$n,0,'UTF-8'):strpos($h,$n); }
function u_len(string $s): int { return function_exists('mb_strlen')?mb_strlen($s,'UTF-8'):strlen($s); }
function u_sub(string $s,int $start,int $len): string { return function_exists('mb_substr')?mb_substr($s,$start,$len,'UTF-8'):substr($s,$start,$len); }

function cfg(): array { static $c=null; if($c===null)$c=require __DIR__.'/config.php'; return $c; }
function catalog(): array { static $c=null; if($c===null)$c=json_decode((string)file_get_contents(__DIR__.'/content/test_catalog.json'),true)?:[]; return $c; }
function books(): array { static $x=null; if($x!==null)return$x; $x=[]; foreach(catalog()['books']??[] as $b)$x[$b['id']]=$b; return$x; }
function loose_pages(): array { static $x=null; if($x!==null)return$x; $x=[]; foreach(catalog()['loose_pages']??[] as $b)$x[$b['id']]=$b; return$x; }
function paths(): array { static $x=null; if($x!==null)return$x; $x=[]; foreach(catalog()['paths']??[] as $b)$x[$b['code']]=$b; return$x; }
function book(string $id): ?array { return books()[$id]??null; }
function loose(string $id): ?array { return loose_pages()[$id]??null; }
function path_obj(string $code): ?array { return paths()[$code]??null; }

function default_state(): array { return ['version'=>1,'readers'=>[],'journeys'=>[],'pulses'=>[],'analytics'=>[]]; }
function with_state(callable $fn){
  $file=cfg()['storage_file']; $dir=dirname($file); if(!is_dir($dir))@mkdir($dir,0775,true);
  $fh=fopen($file,'c+'); if(!$fh) throw new RuntimeException('Cannot open storage/state.json');
  if(!flock($fh,LOCK_EX)){fclose($fh);throw new RuntimeException('Cannot lock state.');}
  rewind($fh);$raw=stream_get_contents($fh);$s=$raw?json_decode($raw,true):null; if(!is_array($s))$s=default_state();
  foreach(['readers','journeys','pulses','analytics'] as $k) if(!isset($s[$k])||!is_array($s[$k]))$s[$k]=[];
  $r=$fn($s);
  rewind($fh);ftruncate($fh,0);fwrite($fh,json_encode($s,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE));fflush($fh);flock($fh,LOCK_UN);fclose($fh);
  return$r;
}
function reader_id(): string {
  $id=$_COOKIE['being_test_reader']??'';
  if(!preg_match('/^R-[A-Z0-9]{10}$/',$id)){
    $id='R-'.strtoupper(substr(bin2hex(random_bytes(8)),0,10));
    if(PHP_SAPI!=='cli')setcookie('being_test_reader',$id,['expires'=>time()+31536000,'path'=>'/','samesite'=>'Lax','httponly'=>true]);
  }
  with_state(function(&$s)use($id){ if(!isset($s['readers'][$id]))$s['readers'][$id]=['owned'=>[],'touched'=>[],'read'=>[],'visited'=>[],'saved_paths'=>[],'created_at'=>gmdate('c')]; return null;});
  return$id;
}
function price_for_pages(int $n): float { foreach(cfg()['price_tiers'] as $t)if($n<=$t['max_pages'])return(float)$t['price']; return 7.99; }
function owned(string $rid,string $bid): bool { return with_state(fn(&$s)=>in_array($bid,$s['readers'][$rid]['owned']??[],true)); }
function touch_book(string $rid,string $bid): void { with_state(function(&$s)use($rid,$bid){$r=&$s['readers'][$rid];if(!in_array($bid,$r['touched'],true))$r['touched'][]=$bid;return null;}); }
function grant(string $rid,string $bid): void { with_state(function(&$s)use($rid,$bid){$r=&$s['readers'][$rid];if(!in_array($bid,$r['owned'],true))$r['owned'][]=$bid;if(!in_array($bid,$r['touched'],true))$r['touched'][]=$bid;$s['analytics'][]=['type'=>'unlock','reader'=>$rid,'book'=>$bid,'at'=>gmdate('c')];return null;}); }

function norm(string $s): string { $s=u_lower(trim($s)); return preg_replace('/[^\p{L}\p{N}\s]+/u',' ',$s)??$s; }
function find_path(string $thought): array {
  $n=norm($thought); $best=null;$bestScore=-1;
  foreach(paths() as $p){$score=0;foreach($p['keywords']??[] as $k)if(u_pos($n,u_lower($k))!==false)$score++; if($score>$bestScore){$bestScore=$score;$best=$p;}}
  if(!$best || $bestScore<=0)return ['status'=>'no_path','message'=>"I don't have a good thread through that yet.",'thought'=>trim($thought)];
  if($bestScore===1)return ['status'=>'partial','message'=>'I can see part of a thread, but not enough to make the whole journey yet.','thought'=>trim($thought)];
  $first=$best['steps'][0]??null;$firstTitle=null;if($first&&$first['kind']==='book')$firstTitle=book($first['id'])['title']??null;
  return ['status'=>'found','code'=>$best['code'],'thought'=>trim($thought),'part_count'=>count($best['steps']),'first_title'=>$firstTitle,'message'=>'I found a thread.'];
}
function save_path(string $rid,string $code): void {with_state(function(&$s)use($rid,$code){$r=&$s['readers'][$rid];if(!in_array($code,$r['saved_paths'],true))$r['saved_paths'][]=$code;return null;});}
function start_journey(string $rid,string $code): array {
  $p=path_obj($code); if(!$p)return['status'=>'error'];$id='J-'.strtoupper(substr(bin2hex(random_bytes(8)),0,12));$first=$p['steps'][0];
  $j=['id'=>$id,'reader'=>$rid,'path_code'=>$code,'mode'=>'thread','step_index'=>0,'current'=>step_start($first),'reached_steps'=>[0],'thread_return'=>null,'created_at'=>gmdate('c')];
  with_state(function(&$s)use($id,$j){$s['journeys'][$id]=$j;return null;});save_path($rid,$code);if($first['kind']==='book')touch_book($rid,$first['id']);return['status'=>'ok','journey'=>$j];
}
function step_start(array $st): array { return $st['kind']==='book'?['kind'=>'book','id'=>$st['id'],'page'=>(int)$st['start']]:['kind'=>'loose','id'=>$st['id'],'page'=>1]; }
function journey(string $id): ?array { return with_state(fn(&$s)=>$s['journeys'][$id]??null); }
function path_step_for(array $j): ?array {$p=path_obj($j['path_code']??'');return$p['steps'][(int)($j['step_index']??0)]??null;}
function visit(string $rid,string $jid,string $kind,string $id,int $page): array {
  if($kind==='book')touch_book($rid,$id);
  return with_state(function(&$s)use($rid,$jid,$kind,$id,$page){
    if($kind==='book' && $page>0){
      $r=&$s['readers'][$rid]; if(!isset($r['visited'][$id]))$r['visited'][$id]=[];if(!in_array($page,$r['visited'][$id],true))$r['visited'][$id][]=$page;sort($r['visited'][$id]);
      $b=book($id); if($b && in_array($id,$r['owned'],true) && count($r['visited'][$id])>=count($b['pages']) && !in_array($id,$r['read'],true))$r['read'][]=$id;
    }
    if(isset($s['journeys'][$jid])){$s['journeys'][$jid]['current']=['kind'=>$kind,'id'=>$id,'page'=>$page];}
    return ['ok'=>true];
  });
}
function advance_thread(string $rid,string $jid): array {
  $j=journey($jid); if(!$j||$j['reader']!==$rid)return['status'=>'error'];if(($j['mode']??'thread')!=='thread')return['status'=>'locked'];
  $p=path_obj($j['path_code']);$i=(int)$j['step_index'];$next=$p['steps'][$i+1]??null;if(!$next)return['status'=>'complete','path_code'=>$j['path_code']];
  $ni=$i+1;$cur=step_start($next);
  with_state(function(&$s)use($jid,$ni,$cur){$jj=&$s['journeys'][$jid];$jj['step_index']=$ni;$jj['current']=$cur;if(!in_array($ni,$jj['reached_steps'],true))$jj['reached_steps'][]=$ni;return null;});
  if($next['kind']==='book')touch_book($rid,$next['id']);return['status'=>'ok','current'=>$cur,'step_index'=>$ni];
}

function retreat_thread(string $rid,string $jid): array {
  $j=journey($jid); if(!$j||$j['reader']!==$rid)return['status'=>'error'];if(($j['mode']??'thread')!=='thread')return['status'=>'locked'];
  $p=path_obj($j['path_code']);$i=(int)$j['step_index'];if($i<=0)return['status'=>'start'];$pi=$i-1;$st=$p['steps'][$pi];
  $cur=$st['kind']==='book'?['kind'=>'book','id'=>$st['id'],'page'=>(int)$st['end']]:['kind'=>'loose','id'=>$st['id'],'page'=>1];
  with_state(function(&$s)use($jid,$pi,$cur){$jj=&$s['journeys'][$jid];$jj['step_index']=$pi;$jj['current']=$cur;return null;});
  return['status'=>'ok','current'=>$cur,'step_index'=>$pi];
}

function thread_context(string $rid,string $jid): array {
  $j=journey($jid);if(!$j||$j['reader']!==$rid)return['status'=>'none'];$p=path_obj($j['path_code']);$st=$p['steps'][(int)$j['step_index']]??null;if(!$st)return['status'=>'none'];
  return ['status'=>'ok','mode'=>$j['mode'],'step_index'=>(int)$j['step_index'],'step_count'=>count($p['steps']),'step'=>$st,'current'=>$j['current'],'has_next'=>isset($p['steps'][(int)$j['step_index']+1]),'thread_return'=>$j['thread_return']??null];
}
function explore_check(string $rid,string $jid): array {$j=journey($jid);if(!$j||$j['reader']!==$rid)return['status'=>'error'];$c=$j['current'];if(($c['kind']??'')!=='book')return['status'=>'not_book'];$b=book($c['id']);return owned($rid,$c['id'])?['status'=>'owned','book'=>$c['id'],'title'=>$b['title']]:['status'=>'purchase_required','book'=>$c['id'],'title'=>$b['title'],'price'=>price_for_pages(count($b['pages']))];}
function explore_book(string $rid,string $jid): array {$j=journey($jid);if(!$j||$j['reader']!==$rid)return['status'=>'error'];$c=$j['current'];if(($c['kind']??'')!=='book'||!owned($rid,$c['id']))return['status'=>'locked'];$anchor=['step_index'=>$j['step_index'],'current'=>$c];with_state(function(&$s)use($jid,$anchor,$c){$jj=&$s['journeys'][$jid];$jj['thread_return']=$anchor;$jj['mode']='book';$jj['current']=['kind'=>'book','id'=>$c['id'],'page'=>0];return null;});return['status'=>'ok','book'=>$c['id'],'page'=>0];}
function return_thread(string $rid,string $jid): array {$j=journey($jid);if(!$j||$j['reader']!==$rid)return['status'=>'error'];$a=$j['thread_return']??null;if(!$a)return['status'=>'none'];with_state(function(&$s)use($jid,$a){$jj=&$s['journeys'][$jid];$jj['mode']='thread';$jj['step_index']=$a['step_index'];$jj['current']=$a['current'];$jj['thread_return']=null;return null;});return['status'=>'ok','current'=>$a['current']];}
function public_book(string $rid,string $bid,string $jid=''): ?array {
  $b=book($bid);if(!$b)return null;$full=owned($rid,$bid);$allowed=[];$access='locked';
  if($full){$allowed=$b['pages'];$access='full';}
  elseif($jid){$j=journey($jid);if($j&&$j['reader']===$rid){$p=path_obj($j['path_code']);foreach($j['reached_steps'] as $idx){$st=$p['steps'][(int)$idx]??null;if($st&&$st['kind']==='book'&&$st['id']===$bid){foreach($b['pages'] as $pg)if($pg['index']>=$st['start']&&$pg['index']<=$st['end'])$allowed[]=$pg;$access='thread';break;}}}}
  return ['id'=>$b['id'],'title'=>$b['title'],'subtitle'=>$b['subtitle'],'cover'=>$b['cover'],'accent'=>$b['accent'],'page_count'=>count($b['pages']),'price'=>price_for_pages(count($b['pages'])),'owned'=>$full,'access'=>$access,'pages'=>$allowed];
}
function public_loose(string $id): ?array {$l=loose($id);if(!$l)return null;return['id'=>$l['id'],'title'=>$l['title'],'paragraphs'=>$l['paragraphs']];}
function map_data(string $rid,string $jid): array {$j=journey($jid);if(!$j||$j['reader']!==$rid)return['status'=>'error'];$p=path_obj($j['path_code']);$nodes=[];foreach($p['steps'] as $i=>$st){$reached=in_array($i,$j['reached_steps'],true);if(!$reached){$nodes[]=['reached'=>false,'kind'=>'unknown'];continue;}if($st['kind']==='loose'){$nodes[]=['reached'=>true,'current'=>$i===$j['step_index'],'kind'=>'loose','title'=>'Loose Page'];}else{$b=book($st['id']);$nodes[]=['reached'=>true,'current'=>$i===$j['step_index'],'kind'=>'book','id'=>$b['id'],'title'=>$b['title'],'page_count'=>count($b['pages']),'start'=>$st['start'],'end'=>$st['end'],'owned'=>owned($rid,$b['id'])];}}return['status'=>'ok','mode'=>$j['mode'],'nodes'=>$nodes,'current'=>$j['current'],'thread_return'=>$j['thread_return']??null];}
function library_data(string $rid): array {return with_state(function(&$s)use($rid){$r=$s['readers'][$rid];$out=[];foreach($r['touched']??[] as $id){$b=book($id);if(!$b)continue;$out[]=['id'=>$id,'title'=>$b['title'],'cover'=>$b['cover'],'owned'=>in_array($id,$r['owned'],true),'read'=>in_array($id,$r['read'],true),'price'=>price_for_pages(count($b['pages']))];}return$out;});}
function leave_pulse(string $rid,string $code,string $text): array {$text=trim(preg_replace('/\s+/u',' ',$text)??'');if(u_len($text)<2)return['status'=>'error'];$text=u_sub($text,0,360);return with_state(function(&$s)use($rid,$code,$text){$id='P-'.strtoupper(substr(bin2hex(random_bytes(6)),0,8));$s['pulses'][$id]=['id'=>$id,'reader'=>$rid,'path_code'=>$code,'text'=>$text,'at'=>gmdate('c')];return['status'=>'ok'];});}
function pulses_for(string $code): array {return with_state(function(&$s)use($code){$out=[];foreach($s['pulses'] as $p)if($p['path_code']===$code)$out[]=['text'=>$p['text'],'at'=>$p['at']];return array_slice(array_reverse($out),0,20);});}
function public_config(): array {return ['version'=>cfg()['version'],'canon'=>false,'experimental'=>true];}
