#!/usr/bin/env python3
"""Rehearsal-only Redis/PHP integration and genuine process recovery tests.
Never installs, changes systemd, connects 6379, touches DM or deletes directories.
"""
import os,sys,json,subprocess,socket,time,secrets,shutil,signal,pwd,hashlib
from pathlib import Path
BASE=Path('/workspace/front-redis627-runtime-20261027')
PHP=Path('/workspace/front-php73-runtime-20261027/build/zhct-php73-kylin-x64-20261027/bin/php')
ALLOWED_PORTS=(16379,16380)
PROCESSES=[]
REPORT={'passed':False,'test':'redis627-independent-runtime','checks':[],'cleanup':[]}

def safe_path(p):
 p=Path(p)
 if not p.is_absolute():raise RuntimeError('absolute path required')
 for q in (p,)+tuple(p.parents):
  if q.is_symlink():raise RuntimeError('symlink path rejected')
 return p

def identity(proc,server,config):
 if proc.poll() is not None:return None
 p=Path('/proc')/str(proc.pid)
 stat=(p/'stat').read_text();start=stat[stat.rfind(')')+2:].split()[19]
 args=(p/'cmdline').read_bytes().split(b'\0')
 # Redis rewrites argv title after starting, so bind executable+starttick, and
 # verify INFO config_file/process_id before every functional command.
 if (p/'exe').resolve()!=server.resolve():raise RuntimeError('child executable changed')
 return (proc.pid,start)

def stop(item):
 proc,server,config,original=item
 if proc.poll() is not None:
  REPORT['cleanup'].append({'pid':proc.pid,'already_exited':True});return
 if identity(proc,server,config)!=original:raise RuntimeError('child identity changed; refusing signal')
 proc.send_signal(signal.SIGTERM)
 try:proc.wait(timeout=10)
 except subprocess.TimeoutExpired:
  if identity(proc,server,config)!=original:raise RuntimeError('child identity changed before kill')
  proc.kill();proc.wait(timeout=5)
 REPORT['cleanup'].append({'pid':proc.pid,'stopped':True,'exit':proc.returncode})

def main():
 if sys.platform!='linux' or os.uname().machine!='x86_64':raise RuntimeError('Linux x64 rehearsal only')
 if len(sys.argv)!=2:raise RuntimeError('Usage: python3 run.py /workspace/front-redis627-runtime-20261027/build/zhct-redis627-kylin-x64-20261027')
 package=safe_path(sys.argv[1]);safe_path(BASE);safe_path(PHP)
 if BASE not in package.parents or package.name!='zhct-redis627-kylin-x64-20261027':raise RuntimeError('package outside rehearsal root')
 server=safe_path(package/'bin/redis-server');client=safe_path(Path(__file__).absolute().parent/'client.php')
 if not server.is_file() or not PHP.is_file() or not client.is_file():raise RuntimeError('runtime missing')
 version=subprocess.check_output([str(server),'--version'],universal_newlines=True)
 if 'v=6.2.7 ' not in version:raise RuntimeError('server version')
 for port in ALLOWED_PORTS:
  s=socket.socket();s.bind(('127.0.0.1',port));s.close()
 uid,gid=os.geteuid(),os.getegid()
 if uid==0:
  account=pwd.getpwnam('nobody');uid,gid=account.pw_uid,account.pw_gid
 run=BASE/('isolated-test-'+time.strftime('%Y%m%d-%H%M%S')+'-'+secrets.token_hex(4));run.mkdir(mode=0o750)
 os.chown(str(run),uid,gid);REPORT['run_directory']=str(run);REPORT['run_uid']=uid;REPORT['run_uid_scope']='Redis server and PHP client subprocesses';REPORT['server_sha256']=hashlib.sha256(server.read_bytes()).hexdigest()
 prefix='__zhct_rehearsal_'+secrets.token_hex(12)+':'
 def demote():
  if os.geteuid()==0:os.setgroups([]);os.setgid(gid);os.setuid(uid)
 def call(phase,item,port):
  proc,exe,conf,bound=item
  if identity(proc,exe,conf)!=bound:raise RuntimeError('process binding before PHP')
  p=subprocess.run([str(PHP),str(client),phase,str(port),str(proc.pid),str(conf),prefix],stdout=subprocess.PIPE,stderr=subprocess.PIPE,universal_newlines=True,timeout=20,preexec_fn=demote)
  if p.returncode:raise RuntimeError('PHP '+phase+' failed: '+p.stderr[:500])
  result=json.loads(p.stdout);REPORT['checks'].append(result);return result
 def start(name,port,aof,source=None):
  if port not in ALLOWED_PORTS:raise RuntimeError('forbidden port')
  data=run/name
  if not data.exists():data.mkdir(mode=0o750);os.chown(str(data),uid,gid)
  if source:
   dst=data/'dump.rdb'
   if dst.exists():raise RuntimeError('RDB destination already exists')
   shutil.copyfile(str(source),str(dst));os.chown(str(dst),uid,gid)
  conf=run/(name+'.conf')
  body='bind 127.0.0.1\nprotected-mode yes\nport %s\ndaemonize no\nsupervised no\ndir %s\npidfile %s\nlogfile %s\ndatabases 16\nsave ""\ndbfilename dump.rdb\nappendonly %s\nappendfilename appendonly.aof\nappendfsync everysec\nmaxmemory 128mb\nmaxmemory-policy noeviction\n' %(port,data,run/(name+'.pid'),run/(name+'.log'),'yes'if aof else'no')
  if conf.exists():
   if conf.read_text()!=body:raise RuntimeError('existing config mismatch')
  else:conf.write_text(body);conf.chmod(0o640);os.chown(str(conf),uid,gid)
  proc=subprocess.Popen([str(server),str(conf)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,preexec_fn=demote)
  # Capture before any client writes; process can briefly still be Python during exec.
  bound=None
  for _ in range(100):
   if proc.poll()is not None:raise RuntimeError('Redis child exited during start')
   try:bound=identity(proc,server,conf)
   except RuntimeError:time.sleep(.02);continue
   if bound:break
  if bound is None:
   proc.terminate();proc.wait(timeout=5);raise RuntimeError('could not bind spawned child')
  item=(proc,server,conf,bound);PROCESSES.append(item)
  for _ in range(100):
   if proc.poll()is not None:raise RuntimeError('Redis child exited before ready')
   try:
    with socket.create_connection(('127.0.0.1',port),.1):break
   except OSError:time.sleep(.05)
  else:raise RuntimeError('Redis startup timeout')
  return item,data
 a,data=start('aof',16379,True);call('write',a,16379);time.sleep(2)
 if not(data/'appendonly.aof').is_file()or(data/'appendonly.aof').stat().st_size==0:raise RuntimeError('AOF not written')
 if(data/'dump.rdb').exists():raise RuntimeError('AOF test unexpectedly has RDB')
 pid1=a[0].pid;stop(a)
 a2,data=start('aof',16379,True)
 if a2[0].pid==pid1:raise RuntimeError('AOF process not replaced')
 if call('verify',a2,16379)['aof_enabled']!=1:raise RuntimeError('AOF disabled')
 REPORT['checks'].append({'aof_real_pid_recovery':True,'old_pid':pid1,'new_pid':a2[0].pid,'rdb_absent':not(data/'dump.rdb').exists()})
 call('save',a2,16379);rdb=data/'dump.rdb'
 if not rdb.is_file()or rdb.stat().st_size==0:raise RuntimeError('RDB absent')
 b,bdata=start('rdb-only',16380,False,rdb)
 if call('verify',b,16380)['aof_enabled']!=0:raise RuntimeError('RDB test AOF enabled')
 if(bdata/'appendonly.aof').exists():raise RuntimeError('RDB test has AOF')
 REPORT['checks'].append({'rdb_independent_restore':True,'rdb_sha256':hashlib.sha256(rdb.read_bytes()).hexdigest(),'aof_absent':True,'new_pid':b[0].pid})
 REPORT['passed']=True
 return run

def interrupted(signum,frame):
 raise KeyboardInterrupt('test interrupted by signal '+str(signum))
signal.signal(signal.SIGTERM,interrupted)
signal.signal(signal.SIGINT,interrupted)
run=None
try:run=main()
except BaseException as e:REPORT['error']={'class':type(e).__name__,'message':str(e)[:600]}
finally:
 for item in reversed(PROCESSES):
  try:stop(item)
  except Exception as e:REPORT['passed']=False;REPORT['cleanup'].append({'pid':item[0].pid,'error':str(e)[:200]})
 report_path=Path(__file__).absolute().parent/('result-'+time.strftime('%Y%m%d-%H%M%S')+'-'+secrets.token_hex(4)+'.json')
 report_path.write_text(json.dumps(REPORT,indent=2)+'\n');report_path.chmod(0o600)
 print(json.dumps({'passed':REPORT['passed'],'checks':len(REPORT['checks']),'report':str(report_path),'error':REPORT.get('error')}))
 sys.exit(0 if REPORT['passed']else 1)
