<?php
declare(strict_types=1);
function ef_validate_addon(string$path):array{$errors=[];$manifest=null;if(!class_exists('ZipArchive'))return['errors'=>['ZIP extension unavailable.'],'manifest'=>null];$z=new ZipArchive;if($z->open($path)!==true)return['errors'=>['Cannot open addon ZIP.'],'manifest'=>null];if($z->numFiles>300)$errors[]='Addon exceeds 300 files.';for($i=0;$i<$z->numFiles;$i++){$n=$z->getNameIndex($i);if(!ef_zip_safe_path($n))$errors[]="Unsafe path: $n";if(!preg_match('~^(addon\.json|migrations/[0-9]{3}_[a-z0-9_-]+\.(?:up|down)\.sql|public/[a-zA-Z0-9_./-]+\.(?:css|js|svg|webp|png)|templates/[a-zA-Z0-9_./-]+\.(?:json|txt)|)$~',$n)&&!str_ends_with($n,'/'))$errors[]="Unsupported addon file: $n";}$raw=$z->getFromName('addon.json');$manifest=$raw!==false?json_decode($raw,true):null;if(!is_array($manifest))$errors[]='Valid addon.json is required.';else{foreach(['id','name','version','minimum_core','permissions','uninstall'] as$k)if(!array_key_exists($k,$manifest))$errors[]="Missing addon field: $k";if(!preg_match('/^[a-z][a-z0-9_-]{2,60}$/',$manifest['id']??''))$errors[]='Invalid addon id.';if(!preg_match('/^\d+\.\d+\.\d+$/',$manifest['version']??''))$errors[]='Invalid addon version.';$core=trim((string)file_get_contents(EF_ROOT.'/VERSION'));if(version_compare($core,(string)($manifest['minimum_core']??'999'),'<'))$errors[]='Addon requires a newer core.';if(!is_array($manifest['permissions']))$errors[]='Permissions must be an array.';if(!in_array($manifest['uninstall'],['keep_data','remove_data'],true))$errors[]='Uninstall must be keep_data or remove_data.';}$z->close();return compact('errors','manifest');}
function ef_safe_migration(string$sql):void{if(preg_match('/\b(DROP\s+(?:DATABASE|USER)|TRUNCATE|GRANT|REVOKE|LOAD_FILE|OUTFILE|INFILE)\b/i',$sql))throw new RuntimeException('Unsafe migration statement blocked.');db()->exec($sql);}
function ef_install_addon(string$path):void{
 $v=ef_validate_addon($path);if($v['errors'])throw new RuntimeException(implode("\n",$v['errors']));$m=$v['manifest'];$existing=one('SELECT * FROM addons WHERE addon_id=?',[$m['id']]);if($existing&&version_compare($m['version'],$existing['version'],'<='))throw new RuntimeException('Addon update version must be newer.');
 $z=new ZipArchive;$z->open($path);$backupDir='storage/backups/addon-'.$m['id'].'-'.date('YmdHis');if($existing&&is_dir(EF_ROOT.'/storage/addons/'.$m['id']))ef_copy_tree(EF_ROOT.'/storage/addons/'.$m['id'],EF_ROOT.'/'.$backupDir);
 $migrations=[];for($i=0;$i<$z->numFiles;$i++){$n=$z->getNameIndex($i);if(str_starts_with($n,'migrations/')&&str_ends_with($n,'.up.sql'))$migrations[]=(string)$z->getFromIndex($i);}foreach($migrations as$sql)ef_safe_migration($sql);
 $pdo=db();$pdo->beginTransaction();try{$target=EF_ROOT.'/storage/addons/'.$m['id'];if(!is_dir($target))mkdir($target,0755,true);for($i=0;$i<$z->numFiles;$i++){$n=$z->getNameIndex($i);if(($n==='addon.json'||str_starts_with($n,'migrations/')||str_starts_with($n,'public/')||str_starts_with($n,'templates/'))&&!str_ends_with($n,'/')){$dest=$target.'/'.$n;if(!is_dir(dirname($dest)))mkdir(dirname($dest),0755,true);if(file_put_contents($dest,$z->getFromIndex($i),LOCK_EX)===false)throw new RuntimeException('Cannot write addon asset.');}}q('INSERT INTO addon_history(addon_id,version,action,manifest,backup_path,created_at) VALUES(?,?,?,?,?,NOW())',[$m['id'],$existing['version']??$m['version'],$existing?'update_from':'install',json_encode($existing?json_decode($existing['manifest'],true):$m),$existing?$backupDir:null]);q('INSERT INTO addons(addon_id,name,version,status,manifest,backup_path,installed_at,updated_at) VALUES(?,?,?,"enabled",?,?,NOW(),NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name),version=VALUES(version),manifest=VALUES(manifest),backup_path=VALUES(backup_path),status="enabled",updated_at=NOW()',[$m['id'],$m['name'],$m['version'],json_encode($m),$existing?$backupDir:null]);$pdo->commit();audit('addon.install',['id'=>$m['id'],'version'=>$m['version']]);}catch(Throwable$e){if($pdo->inTransaction())$pdo->rollBack();throw$e;}finally{$z->close();}
}
function ef_copy_tree(string$source,string$dest):void{if(!is_dir($dest))mkdir($dest,0755,true);foreach(scandir($source)?:[] as$n){if($n==='.'||$n==='..')continue;$s=$source.'/'.$n;$d=$dest.'/'.$n;if(is_dir($s))ef_copy_tree($s,$d);else copy($s,$d);}}
function ef_remove_tree(string$dir):void{if(!is_dir($dir))return;foreach(scandir($dir)?:[] as$n){if($n==='.'||$n==='..')continue;$p=$dir.'/'.$n;if(is_dir($p))ef_remove_tree($p);else unlink($p);}rmdir($dir);}
function ef_toggle_addon(int$id,string$status):void{if(!in_array($status,['enabled','disabled'],true))throw new RuntimeException('Invalid addon status.');q('UPDATE addons SET status=?,updated_at=NOW() WHERE id=?',[$status,$id]);audit('addon.'.$status,['id'=>$id]);}
function ef_rollback_addon(int$id):void{$a=one('SELECT * FROM addons WHERE id=?',[$id]);if(!$a||!$a['backup_path'])throw new RuntimeException('No addon backup is available.');$history=one('SELECT * FROM addon_history WHERE addon_id=? AND backup_path=? ORDER BY id DESC',[$a['addon_id'],$a['backup_path']]);if(!$history||!is_dir(EF_ROOT.'/'.$a['backup_path']))throw new RuntimeException('Addon rollback files are missing.');$target=EF_ROOT.'/storage/addons/'.$a['addon_id'];ef_remove_tree($target);ef_copy_tree(EF_ROOT.'/'.$a['backup_path'],$target);$m=json_decode($history['manifest'],true)?:[];q('UPDATE addons SET name=?,version=?,manifest=?,backup_path=NULL,updated_at=NOW() WHERE id=?',[$m['name']??$a['name'],$history['version'],json_encode($m),$id]);q('INSERT INTO addon_history(addon_id,version,action,manifest,created_at) VALUES(?,? ,"rollback",?,NOW())',[$a['addon_id'],$history['version'],json_encode($m)]);audit('addon.rollback',['id'=>$id]);}
function ef_uninstall_addon(int$id):void{$a=one('SELECT * FROM addons WHERE id=?',[$id]);if(!$a)throw new RuntimeException('Addon not found.');$m=json_decode($a['manifest'],true)?:[];if(($m['uninstall']??'keep_data')==='remove_data'){foreach(glob(EF_ROOT.'/storage/addons/'.$a['addon_id'].'/migrations/*.down.sql')?:[] as$sql)ef_safe_migration((string)file_get_contents($sql));}ef_remove_tree(EF_ROOT.'/storage/addons/'.$a['addon_id']);q('INSERT INTO addon_history(addon_id,version,action,manifest,created_at) VALUES(?,? ,"uninstall",?,NOW())',[$a['addon_id'],$a['version'],$a['manifest']]);q('DELETE FROM addons WHERE id=?',[$id]);audit('addon.uninstall',['id'=>$id]);}
