<?php
declare(strict_types=1);

function ef_update_safe_path(string $path):bool
{
    if($path===''||str_starts_with($path,'/')||str_contains($path,'..')||str_contains($path,'\\')||str_contains($path,"\0"))return false;
    if(in_array($path,['config.php','.env','storage/install.lock'],true)||preg_match('~^(?:storage|uploads)/~',$path))return false;
    return(bool)preg_match('~^(?:app/[a-zA-Z0-9_.-]+\.php|admin/index\.php|install/index\.php|assets/[a-zA-Z0-9_./-]+|database/(?:schema\.sql|migrations/[a-zA-Z0-9_.-]+\.sql)|docs/[a-zA-Z0-9_.-]+|templates/[a-zA-Z0-9_./-]+|index\.php|\.htaccess|VERSION|service-worker\.js|manifest\.webmanifest|viewer\.html)$~',$path);
}

function ef_update_validate(string $zipPath):array
{
    if(!class_exists('ZipArchive'))throw new RuntimeException('ZIP extension is required.');$zip=new ZipArchive;if($zip->open($zipPath)!==true)throw new RuntimeException('Cannot open upgrade ZIP.');$errors=[];$manifest=null;
    try{if($zip->numFiles>2500)$errors[]='Upgrade ZIP exceeds 2,500 entries.';$total=0;$seen=[];for($i=0;$i<$zip->numFiles;$i++){$s=$zip->statIndex($i);$name=$s['name'];$total+=(int)$s['size'];if(!ef_zip_safe_path($name))$errors[]='Unsafe ZIP path: '.$name;if(isset($seen[strtolower($name)]))$errors[]='Duplicate ZIP path: '.$name;$seen[strtolower($name)]=1;}if($total>150*1024*1024)$errors[]='Upgrade expands beyond 150 MB.';$raw=$zip->getFromName('update.json');$manifest=$raw===false?null:json_decode($raw,true);if(!is_array($manifest))$errors[]='Valid update.json is required.';else{foreach(['package_id','version','minimum_version','files'] as$k)if(!array_key_exists($k,$manifest))$errors[]='Missing update field: '.$k;if(($manifest['package_id']??'')!=='EXAMSFIT-CORE')$errors[]='This is not an Exams.fit core update.';$current=trim((string)file_get_contents(EF_ROOT.'/VERSION'));$target=(string)($manifest['version']??'0');if(!preg_match('/^\d+\.\d+\.\d+$/',$target))$errors[]='Target version must be semantic.';if(version_compare($target,$current,'<='))$errors[]='Upgrade version must be newer than '.$current.'.';if(version_compare($current,(string)($manifest['minimum_version']??'999'),'<'))$errors[]='Current version is below this package minimum.';if(!empty($manifest['maximum_version'])&&version_compare($current,(string)$manifest['maximum_version'],'>'))$errors[]='Current version is newer than this package supports.';
        if(!is_array($manifest['files'])||!$manifest['files'])$errors[]='Upgrade contains no files.';foreach($manifest['files']??[] as$i=>$f){$path=is_array($f)?(string)($f['path']??''):'';$sha=is_array($f)?(string)($f['sha256']??''):'';$entry='payload/'.$path;if(!ef_update_safe_path($path)){$errors[]='Blocked target path: '.$path;continue;}$bytes=$zip->getFromName($entry);if($bytes===false){$errors[]='Missing '.$entry;continue;}if(!preg_match('/^[a-f0-9]{64}$/i',$sha)||!hash_equals(strtolower($sha),hash('sha256',$bytes)))$errors[]='Checksum mismatch: '.$path;}
        foreach($manifest['migrations']??[] as$m){$file=is_array($m)?(string)($m['file']??''):'';$sha=is_array($m)?(string)($m['sha256']??''):'';if(!preg_match('~^migrations/[a-zA-Z0-9_.-]+\.sql$~',$file)){$errors[]='Invalid migration path.';continue;}$sql=$zip->getFromName($file);if($sql===false||!preg_match('/^[a-f0-9]{64}$/i',$sha)||!hash_equals(strtolower($sha),hash('sha256',$sql)))$errors[]='Migration checksum mismatch: '.$file;elseif(preg_match('/\b(DROP|TRUNCATE|DELETE|UPDATE|REPLACE|RENAME|GRANT|REVOKE|OUTFILE|INFILE)\b|ALTER\s+TABLE\s+.+\s+(?:DROP|CHANGE|MODIFY)\b/i',$sql))$errors[]='Destructive or mutating migration blocked: '.$file;}
    }}finally{$zip->close();}return['errors'=>array_values(array_unique($errors)),'manifest'=>$manifest,'current'=>trim((string)file_get_contents(EF_ROOT.'/VERSION'))];
}

function ef_system_update_install(string $zipPath):array
{
    $check=ef_update_validate($zipPath);if($check['errors'])throw new RuntimeException(implode("\n",$check['errors']));$m=$check['manifest'];$stamp=date('Ymd_His');$backupName='ExamsFit_update_files_'.$check['current'].'_'.$stamp.'.zip';$backupPath=EF_ROOT.'/storage/backups/'.$backupName;$dbBackup=ef_backup_create();$recovery=['from'=>$check['current'],'to'=>$m['version'],'created'=>[],'database_backup'=>$dbBackup,'at'=>date(DATE_ATOM)];$backup=new ZipArchive;if($backup->open($backupPath,ZipArchive::CREATE|ZipArchive::EXCL)!==true)throw new RuntimeException('Cannot create update recovery ZIP.');$zip=new ZipArchive;$zip->open($zipPath);$written=[];$lock=EF_ROOT.'/storage/maintenance.lock';file_put_contents($lock,json_encode(['version'=>$m['version'],'started'=>date(DATE_ATOM)]),LOCK_EX);
    try{foreach($m['files'] as$f){$path=$f['path'];$dest=EF_ROOT.'/'.$path;if(is_file($dest)){$original=file_get_contents($dest);if($original===false||!$backup->addFromString('files/'.$path,$original))throw new RuntimeException('Cannot back up '.$path);}else$recovery['created'][]=$path;$bytes=$zip->getFromName('payload/'.$path);if(!is_dir(dirname($dest))&&!mkdir(dirname($dest),0755,true))throw new RuntimeException('Cannot create directory for '.$path);$temp=$dest.'.update-'.bin2hex(random_bytes(4));if(file_put_contents($temp,$bytes,LOCK_EX)===false||!rename($temp,$dest))throw new RuntimeException('Cannot replace '.$path);$written[]=$path;}
        foreach($m['migrations']??[] as$migration){$sql=(string)$zip->getFromName($migration['file']);ef_update_run_migration($sql,$migration['file']);}
        if(trim((string)file_get_contents(EF_ROOT.'/VERSION'))!==(string)$m['version'])throw new RuntimeException('Installed VERSION does not match update manifest.');foreach(['index.php','app/bootstrap.php','app/core.php','admin/index.php'] as$required)if(!is_file(EF_ROOT.'/'.$required)||filesize(EF_ROOT.'/'.$required)<20)throw new RuntimeException('Post-update health check failed: '.$required);$recovery['written']=$written;$backup->addFromString('recovery.json',json_encode($recovery,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));$backup->close();$zip->close();ef_cache_clear();ef_update_log(['status'=>'complete','from'=>$check['current'],'to'=>$m['version'],'backup'=>$backupName,'database_backup'=>$dbBackup]);audit('system.update',['from'=>$check['current'],'to'=>$m['version'],'backup'=>$backupName,'database_backup'=>$dbBackup]);return['from'=>$check['current'],'to'=>$m['version'],'backup'=>$backupName,'database_backup'=>$dbBackup];
    }catch(Throwable$e){$backup->addFromString('recovery.json',json_encode($recovery+['written'=>$written],JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));$backup->close();$zip->close();try{ef_system_update_rollback($backupName,false);}catch(Throwable){}ef_update_log(['status'=>'failed','from'=>$check['current'],'to'=>$m['version'],'message'=>$e->getMessage(),'backup'=>$backupName,'database_backup'=>$dbBackup]);throw new RuntimeException('Upgrade failed and file rollback was attempted. Database backup: '.$dbBackup.'. '.$e->getMessage());}finally{@unlink($lock);}
}

function ef_update_run_migration(string $sql,string $name):void
{
    if(preg_match('/\b(DROP|TRUNCATE|DELETE|UPDATE|REPLACE|RENAME|GRANT|REVOKE|OUTFILE|INFILE)\b|ALTER\s+TABLE\s+.+\s+(?:DROP|CHANGE|MODIFY)\b/i',$sql))throw new RuntimeException('Destructive or mutating migration blocked: '.$name);$clean=preg_replace('/^\s*--.*$/m','',$sql);$statements=array_values(array_filter(array_map('trim',preg_split('/;\s*(?:\r?\n|$)/',$clean)?:[])));foreach($statements as$statement){try{db()->exec($statement);}catch(PDOException$e){$msg=strtolower($e->getMessage());if(!str_contains($msg,'duplicate column')&&!str_contains($msg,'duplicate key')&&!str_contains($msg,'already exists'))throw$e;}}
}

function ef_system_update_rollback(string $backupName,bool $record=true):void
{
    $backupName=basename($backupName);if(!preg_match('/^ExamsFit_update_files_[0-9.]+_\d{8}_\d{6}\.zip$/',$backupName))throw new RuntimeException('Invalid recovery ZIP.');$path=EF_ROOT.'/storage/backups/'.$backupName;if(!is_file($path))throw new RuntimeException('Recovery ZIP is missing.');$zip=new ZipArchive;if($zip->open($path)!==true)throw new RuntimeException('Cannot open recovery ZIP.');$recovery=json_decode((string)$zip->getFromName('recovery.json'),true);if(!is_array($recovery)||empty($recovery['from'])){$zip->close();throw new RuntimeException('Recovery manifest is invalid.');}$lock=EF_ROOT.'/storage/maintenance.lock';file_put_contents($lock,'rollback',LOCK_EX);try{for($i=0;$i<$zip->numFiles;$i++){$name=$zip->getNameIndex($i);if(!str_starts_with($name,'files/'))continue;$target=substr($name,6);if(!ef_update_safe_path($target))throw new RuntimeException('Unsafe recovery path.');$dest=EF_ROOT.'/'.$target;$bytes=$zip->getFromIndex($i);if(!is_dir(dirname($dest)))mkdir(dirname($dest),0755,true);if(file_put_contents($dest,$bytes,LOCK_EX)===false)throw new RuntimeException('Could not restore '.$target);}foreach($recovery['created']??[] as$created)if(ef_update_safe_path($created)&&is_file(EF_ROOT.'/'.$created))unlink(EF_ROOT.'/'.$created);$zip->close();ef_cache_clear();if($record){ef_update_log(['status'=>'rolled_back','from'=>$recovery['to']??'unknown','to'=>$recovery['from'],'backup'=>$backupName,'database_backup'=>$recovery['database_backup']??null]);audit('system.update.rollback',['backup'=>$backupName]);}}finally{@unlink($lock);}
}

function ef_update_log(array $row):void
{
    $row=['at'=>date(DATE_ATOM),'admin'=>admin_user()['email']??null]+$row;$path=EF_ROOT.'/storage/logs/system-updates.jsonl';file_put_contents($path,json_encode($row,JSON_UNESCAPED_SLASHES)."\n",FILE_APPEND|LOCK_EX);
}

function ef_update_history():array
{
    $path=EF_ROOT.'/storage/logs/system-updates.jsonl';if(!is_file($path))return[];$lines=array_slice(file($path,FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES)?:[],-50);$rows=[];foreach(array_reverse($lines) as$line){$x=json_decode($line,true);if(is_array($x))$rows[]=$x;}return$rows;
}
