<?php
defined('ABSPATH')||exit;
/* A backup engine must stream files, create protected directories, and export
 * arbitrary WordPress table identifiers discovered directly from the database.
 * WP_Filesystem is interactive and cannot safely stream multi-gigabyte archives.
 * Table names come only from SHOW FULL TABLES and are backtick escaped. */
// phpcs:disable WordPress.WP.AlternativeFunctions,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL,PluginCheck.Security.DirectDB,WordPress.Security.EscapeOutput.ExceptionNotEscaped
final class Fortis_WP_Backup_Job
{
    public static function home():string{$uploads=wp_upload_dir();return untrailingslashit(wp_normalize_path((string)$uploads['basedir'])).'/fortisrestore-data';}public static function backup_dir():string{return self::home().'/backups';}public static function work_dir():string{return self::home().'/work';}
    public function start():array
    {
        $this->ensure();if($active=$this->active())return$active;$id=gmdate('YmdHis').'-'.bin2hex(random_bytes(4));$directory=self::work_dir().'/'.$id;mkdir($directory,0700,true);$files=$this->files();$tables=$this->tables();$archive=self::backup_dir().'/fortis-wp-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)).'.fts';$state=['id'=>$id,'status'=>'running','phase'=>'database','percent'=>0,'message'=>'Preparing WordPress database','tables'=>$tables,'table_index'=>0,'files'=>$files,'file_index'=>0,'database'=>$directory.'/database.sql','archive'=>$archive,'total'=>count($tables)+count($files)+1,'processed'=>0,'created'=>time()];file_put_contents($state['database'],"-- FortisRestore WordPress database export\nSET FOREIGN_KEY_CHECKS=0;\n\n",LOCK_EX);file_put_contents(self::work_dir().'/active',$id,LOCK_EX);$this->save($state);Fortis_WP_Log::write('backup_start','success','WordPress backup started.',['job'=>$id]);return$this->public_state($state);
    }
    public function step(string$id):array
    {
        $state=$this->load($id);if($state['status']!=='running')return$this->public_state($state);try{if($state['phase']==='database'){$end=min(count($state['tables']),$state['table_index']+3);for($i=$state['table_index'];$i<$end;$i++)$this->dump_table($state['tables'][$i],$state['database']);$state['processed']+=($end-$state['table_index']);$state['table_index']=$end;$state['message']='Exported '.$end.' of '.count($state['tables']).' WordPress tables';if($end>=count($state['tables'])){file_put_contents($state['database'],"SET FOREIGN_KEY_CHECKS=1;\n",FILE_APPEND|LOCK_EX);$this->initialize($state);$state['phase']='files';$state['message']='Database secured; adding WordPress files';}}elseif($state['phase']==='files'){$end=min(count($state['files']),$state['file_index']+120);$this->add_files($state,$end);$state['processed']+=($end-$state['file_index']);$state['file_index']=$end;$state['message']='Secured '.$end.' of '.count($state['files']).' files';if($end>=count($state['files'])){$this->finalize($state);$state['size']=filesize($state['archive']);$state['processed']=$state['total'];$state['status']='complete';$state['phase']='complete';$state['message']='Encrypted WordPress backup created and verified';$state['completed']=time();Fortis_WP_Log::write('backup_complete','success',$state['message'],['archive'=>basename($state['archive']),'size'=>$state['size']]);}}$state['percent']=(int)min(100,floor(($state['processed']/max(1,$state['total']))*100));}catch(Throwable$error){$state['status']='failed';$state['phase']='failed';$state['message']=$error->getMessage();$this->unlock();Fortis_WP_Log::write('backup_failed','failed',$error->getMessage());}$this->save($state);return$this->public_state($state);
    }
    public function active():?array{$path=self::work_dir().'/active';if(!is_file($path))return null;try{$state=$this->load(trim((string)file_get_contents($path)));return$state['status']==='running'?$this->public_state($state):null;}catch(Throwable){return null;}}
    public function archives():array{$items=[];foreach(glob(self::backup_dir().'/fortis-wp-*.fts')?:[]as$path){$items[]=['name'=>basename($path),'size'=>filesize($path),'created'=>filemtime($path),'verified'=>$this->verify($path)];}usort($items,fn($a,$b)=>$b['created']<=>$a['created']);return$items;}
    public function archive_path(string$name):string{if(!preg_match('/^fortis-wp-[A-Za-z0-9-]+\.fts$/',$name))wp_die('Invalid Fortis archive.',400);return self::backup_dir().'/'.$name;}
    public function recovery_key():string{return $this->key();}
    public function import(string$tmp,string$sourceKey=''):string{$this->ensure();if(!is_uploaded_file($tmp)&&!is_file($tmp))throw new RuntimeException('Uploaded archive is unavailable.');$work=self::work_dir().'/import-'.bin2hex(random_bytes(5));mkdir($work,0700,true);$source=new ZipArchive();if($source->open($tmp,ZipArchive::CHECKCONS)!==true)throw new RuntimeException('Uploaded .fts archive is invalid.');$source->setPassword($sourceKey!==''?$sourceKey:$this->key());$manifest=json_decode((string)$source->getFromName('fortis.json'),true);if(($manifest['site']['cms']??'')!=='wordpress'||$source->getFromName('files/wp-config.php')===false)throw new RuntimeException('The archive is not a FortisRestore WordPress recovery point.');if(!$source->extractTo($work))throw new RuntimeException('Unable to decrypt the archive. Check its recovery key.');$source->close();$target=self::backup_dir().'/fortis-wp-imported-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)).'.fts';$zip=new ZipArchive();$zip->open($target,ZipArchive::CREATE|ZipArchive::OVERWRITE);$iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($work,FilesystemIterator::SKIP_DOTS));foreach($iterator as$file){if(!$file->isFile())continue;$relative=str_replace(DIRECTORY_SEPARATOR,'/',substr($file->getPathname(),strlen($work)+1));$this->encrypted_add($zip,$file->getPathname(),$relative);}$zip->close();$this->remove_tree($work);chmod($target,0600);if(!$this->verify($target))throw new RuntimeException('Imported archive verification failed.');return basename($target);}
    public function verify(string$path):bool{$zip=new ZipArchive();if($zip->open($path,ZipArchive::CHECKCONS)!==true)return false;$zip->setPassword($this->key());$manifest=json_decode((string)$zip->getFromName('fortis.json'),true);$database=$zip->getFromName('database.sql');$config=$zip->getFromName('files/wp-config.php');$zip->close();return($manifest['format']??'')==='fortis-archive'&&($manifest['site']['cms']??'')==='wordpress'&&$database!==false&&$config!==false;}
    private function ensure():void{foreach([self::home(),self::backup_dir(),self::work_dir()]as$dir){if(!is_dir($dir))wp_mkdir_p($dir);chmod($dir,0700);}foreach(['index.php'=>"<?php\n// Silence is golden.\n",'.htaccess'=>"Require all denied\n"]as$name=>$content){$path=self::home().'/'.$name;if(!is_file($path))file_put_contents($path,$content,LOCK_EX);}}
    private function tables():array{global$wpdb;return$wpdb->get_col('SHOW FULL TABLES WHERE Table_type="BASE TABLE"');}
    private function files():array{$files=[];$root=$this->site_root();$backup=Fortis_WP_Settings::get()['backup'];$excluded=array_map(static fn(string$p):string=>trim(str_replace('\\','/',$p),'/').'/',(array)($backup['exclusions']??[]));if(!empty($backup['exclude_cache']))$excluded[]='wp-content/cache/';if(!empty($backup['exclude_upgrade']))$excluded[]='wp-content/upgrade/';foreach([self::home(),trailingslashit((string)wp_upload_dir()['basedir']).'fortisrestore-staging']as$path)if(str_starts_with(wp_normalize_path($path),$root))$excluded[]=trim(substr(wp_normalize_path($path),strlen($root)),'/').'/';$iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root,FilesystemIterator::SKIP_DOTS));foreach($iterator as$file){if(!$file->isFile()||$file->isLink())continue;$relative=str_replace(DIRECTORY_SEPARATOR,'/',substr(wp_normalize_path($file->getPathname()),strlen($root)));foreach($excluded as$prefix)if($prefix!==''&&str_starts_with($relative,$prefix))continue 2;$files[]=$relative;}sort($files);return$files;}
    private function dump_table(string$table,string$target):void{global$wpdb;$safe=str_replace('`','``',$table);$create=$wpdb->get_row('SHOW CREATE TABLE `'.$safe.'`',ARRAY_N);file_put_contents($target,"DROP TABLE IF EXISTS `{$safe}`;\n{$create[1]};\n",FILE_APPEND|LOCK_EX);$offset=0;do{$rows=$wpdb->get_results('SELECT * FROM `'.$safe.'` LIMIT 500 OFFSET '.$offset,ARRAY_A);foreach($rows as$row){$values=array_map(fn($value)=>$value===null?'NULL':"'".$wpdb->_real_escape((string)$value)."'",array_values($row));file_put_contents($target,'INSERT INTO `'.$safe.'` VALUES ('.implode(',',$values).");\n",FILE_APPEND|LOCK_EX);}$offset+=count($rows);}while(count($rows)===500);file_put_contents($target,"\n",FILE_APPEND|LOCK_EX);}
    private function initialize(array$state):void{$manifest=['format'=>'fortis-archive','format_version'=>1,'created_utc'=>gmdate(DATE_ATOM),'generator'=>['name'=>'FortisRestore for WordPress','version'=>'1.2.1'],'site'=>['url'=>home_url(),'host'=>wp_parse_url(home_url(),PHP_URL_HOST),'cms'=>'wordpress','wordpress'=>get_bloginfo('version'),'multisite'=>is_multisite()],'payload'=>['database'=>'database.sql','files_root'=>'files/'],'security'=>['encryption'=>'AES-256','key_scope'=>'site-master-key']];$manifestPath=dirname($state['database']).'/fortis.json';file_put_contents($manifestPath,json_encode($manifest,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES),LOCK_EX);$zip=new ZipArchive();if($zip->open($state['archive'],ZipArchive::CREATE|ZipArchive::OVERWRITE)!==true)throw new RuntimeException('Unable to create the Fortis archive.');$this->encrypted_add($zip,$manifestPath,'fortis.json');$this->encrypted_add($zip,$state['database'],'database.sql');$zip->close();}
    private function add_files(array$state,int$end):void{$zip=new ZipArchive();if($zip->open($state['archive'])!==true)throw new RuntimeException('Unable to resume the Fortis archive.');$root=$this->site_root();for($i=$state['file_index'];$i<$end;$i++){$relative=$state['files'][$i];$source=$root.$relative;if(is_file($source))$this->encrypted_add($zip,$source,'files/'.$relative);}if(!$zip->close())throw new RuntimeException('Unable to save the Fortis archive.');}
    private function encrypted_add(ZipArchive$zip,string$source,string$name):void{if(!$zip->addFile($source,$name))throw new RuntimeException('Unable to add '.$name);$zip->setPassword($this->key());if(!$zip->setEncryptionName($name,ZipArchive::EM_AES_256))throw new RuntimeException('AES-256 ZIP encryption is unavailable.');}
    private function finalize(array$state):void{if(!$this->verify($state['archive']))throw new RuntimeException('Final WordPress archive verification failed.');chmod($state['archive'],0600);foreach([dirname($state['database']).'/fortis.json',$state['database']]as$file)if(is_file($file))unlink($file);@rmdir(dirname($state['database']));$this->unlock();}
    private function key():string{$path=self::home().'/.fortis-wp-master-key';if(!is_file($path)){file_put_contents($path,rtrim(strtr(base64_encode(random_bytes(32)),'+/','-_'),'='),LOCK_EX);chmod($path,0600);}return trim((string)file_get_contents($path));}
    private function site_root():string{if(!function_exists('get_home_path'))throw new RuntimeException('WordPress home-path services are unavailable.');return trailingslashit(wp_normalize_path(get_home_path()));}
    private function state_path(string$id):string{if(!preg_match('/^[0-9]{14}-[a-f0-9]{8}$/',$id))throw new RuntimeException('Invalid backup job.');return self::work_dir().'/job-'.$id.'.json';}private function save(array$state):void{file_put_contents($this->state_path($state['id']),json_encode($state),LOCK_EX);chmod($this->state_path($state['id']),0600);}private function load(string$id):array{$state=json_decode((string)@file_get_contents($this->state_path($id)),true);if(!is_array($state))throw new RuntimeException('Backup job not found.');return$state;}private function unlock():void{$path=self::work_dir().'/active';if(is_file($path))unlink($path);}private function public_state(array$s):array{return['id'=>$s['id'],'status'=>$s['status'],'phase'=>$s['phase'],'percent'=>(int)$s['percent'],'message'=>$s['message'],'archive'=>basename($s['archive']),'size'=>(int)($s['size']??0)];}
    private function remove_tree(string$path):void{$iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);foreach($iterator as$item)$item->isDir()?rmdir($item->getPathname()):unlink($item->getPathname());rmdir($path);}
}
