<?php
defined('ABSPATH') || exit;
// Restores intentionally use a separate WordPress database connection.
// phpcs:disable WordPress.WP.AlternativeFunctions,WordPress.Security.EscapeOutput.ExceptionNotEscaped,Squiz.PHP.DiscouragedFunctions.Discouraged
final class Fortis_WP_Restore_Service
{
    private const FILE_BATCH=80, SQL_BATCH=120, ROW_BATCH=250;

    public function start(string $archive,string $folder,string $database):array
    {
        $this->ensure(); if($active=$this->active())return$active;
        $archive=realpath($archive)?:$archive;$destination=$this->destination($folder);$target=$this->safeTarget($destination['path'],$destination['root']);
        if(!preg_match('/^[A-Za-z0-9_]+$/',$database))throw new RuntimeException('Invalid restore database name.');
        if(strcasecmp($database,(string)DB_NAME)===0)throw new RuntimeException('The live WordPress database cannot be used as a restore destination.');
        if(!is_file($archive)||!is_readable($archive))throw new RuntimeException('Fortis archive not found.');$db=$this->emptyDatabase($database);$db->close();
        $zip=$this->open($archive);$check=$this->validate($zip,$target);$manifest=json_decode((string)$zip->getFromName('fortis.json'),true);
        if(($manifest['format']??'')!=='fortis-archive'||($manifest['site']['cms']??'')!=='wordpress'||$zip->getFromName('database.sql')===false||$zip->getFromName('files/wp-config.php')===false){$zip->close();throw new RuntimeException('This is not a complete FortisRestore WordPress archive.');}$zip->close();
        $id=gmdate('YmdHis').'-'.bin2hex(random_bytes(4));$work=Fortis_WP_Backup_Job::work_dir().'/restore-'.$id;if(!mkdir($work,0700,true)&&!is_dir($work))throw new RuntimeException('Unable to create the protected restore workspace.');
        $state=['id'=>$id,'status'=>'running','phase'=>'extract','percent'=>1,'message'=>'Archive verified; preparing files','archive'=>$archive,'target'=>$target,'database'=>$database,'root'=>$destination['root'],'url'=>$destination['url'],'work'=>$work,'target_existed'=>is_dir($target),'zip_index'=>0,'zip_total'=>$check['entries'],'bytes_total'=>$check['bytes'],'bytes_done'=>0,'sql_offset'=>0,'sql_statements'=>0,'manifest'=>$manifest,'created'=>time(),'updated'=>time()];
        $this->save($state);file_put_contents($this->activePath(),$id,LOCK_EX);chmod($this->activePath(),0600);Fortis_WP_Log::write('restore_start','success','Resumable WordPress restore started.',['job'=>$id,'archive'=>basename($archive)]);return$this->publicState($state);
    }

    public function step(string$id):array
    {
        $s=$this->load($id);if($s['status']!=='running')return$this->publicState($s);
        try{match($s['phase']){'extract'=>$this->extract($s),'configure'=>$this->configure($s),'database'=>$this->database($s),'prepare_urls'=>$this->prepareUrls($s),'rewrite'=>$this->rewrite($s),'files'=>$this->textFiles($s),'verify'=>$this->verifyStep($s),'cleanup'=>$this->complete($s),default=>throw new RuntimeException('Unknown restore phase.')};}
        catch(Throwable$error){$s['status']='failed';$s['phase']='failed';$s['message']=$error->getMessage();$s['updated']=time();$this->cleanupFailure($s);$this->unlock();Fortis_WP_Log::write('restore_failed','failed',$error->getMessage(),['job'=>$id]);}
        $this->save($s);return$this->publicState($s);
    }
    public function active():?array{if(!is_file($this->activePath()))return null;try{$s=$this->load(trim((string)file_get_contents($this->activePath())));return$s['status']==='running'?$this->publicState($s):null;}catch(Throwable){$this->unlock();return null;}}
    public function cancel(string$id):array{$s=$this->load($id);if($s['status']==='running'){$this->cleanupFailure($s);$s['status']='cancelled';$s['phase']='cancelled';$s['message']='Restore cancelled and partial destination removed.';$s['updated']=time();$this->unlock();$this->save($s);Fortis_WP_Log::write('restore_cancelled','success',$s['message'],['job'=>$id]);}return$this->publicState($s);}

    private function extract(array&$s):void
    {
        $zip=$this->open($s['archive']);$started=microtime(true);$end=min($s['zip_total'],$s['zip_index']+self::FILE_BATCH);
        for($i=$s['zip_index'];$i<$end&&microtime(true)-$started<4;$i++){$stat=$zip->statIndex($i);$name=str_replace('\\','/',(string)($stat['name']??''));if(str_ends_with($name,'/')){if(str_starts_with($name,'files/'))$this->mkdir($s['target'].'/'.substr($name,6));}elseif($name==='database.sql')$this->extractEntry($zip,$name,$s['work'].'/database.sql',0600);elseif($name==='fortis.json')$this->extractEntry($zip,$name,$s['work'].'/fortis.json',0600);elseif(str_starts_with($name,'files/'))$this->extractEntry($zip,$name,$s['target'].'/'.substr($name,6),0644);$s['bytes_done']+=(int)($stat['size']??0);$s['zip_index']=$i+1;}
        $zip->close();$s['percent']=max(2,min(45,2+(int)floor(43*$s['bytes_done']/max(1,$s['bytes_total']))));$s['message']='Restored '.$s['zip_index'].' of '.$s['zip_total'].' encrypted archive entries';if($s['zip_index']>=$s['zip_total']){if(!is_file($s['work'].'/database.sql'))throw new RuntimeException('Archive database payload is missing.');$s['phase']='configure';$s['percent']=46;$s['message']='Files restored; securing configuration';}$s['updated']=time();
    }
    private function configure(array&$s):void
    {
        $config=$s['target'].'/wp-config.php';if(!is_file($config))throw new RuntimeException('Restored WordPress configuration is missing.');$data=(string)file_get_contents($config);$database=$s['database'];$data=(string)preg_replace_callback("/(define\(\s*['\"]DB_NAME['\"]\s*,\s*)['\"][^'\"]*['\"](\s*\);)/",static fn(array$m):string=>$m[1]."'".$database."'".$m[2],$data);if(file_put_contents($config,$data,LOCK_EX)===false)throw new RuntimeException('Unable to secure the restored WordPress configuration.');chmod($config,0600);$s['prefix']='wp_';if(preg_match('/\$table_prefix\s*=\s*[\'\"]([^\'\"]+)[\'\"]\s*;/',$data,$m))$s['prefix']=$m[1];$s['phase']='database';$s['percent']=48;$s['message']='Configuration secured; importing database';$s['updated']=time();
    }
    private function database(array&$s):void
    {
        $db=$this->connect($s['database']);$path=$s['work'].'/database.sql';$h=fopen($path,'rb');if(!$h){$db->close();throw new RuntimeException('Unable to read the database payload.');}fseek($h,(int)$s['sql_offset']);$started=microtime(true);$count=0;$statement='';
        while(!feof($h)&&$count<self::SQL_BATCH&&microtime(true)-$started<4){$line=fgets($h);if($line===false)break;$trim=trim($line);if($statement===''&&($trim===''||str_starts_with($trim,'--'))){$s['sql_offset']=ftell($h);continue;}$statement.=$line;if(!preg_match('/;\s*$/',$trim))continue;if($db->query($statement)===false){$message=$db->last_error;fclose($h);$db->close();throw new RuntimeException('Database restore failed: '.$message);}$statement='';$count++;$s['sql_statements']++;$s['sql_offset']=ftell($h);}
        $done=feof($h);$size=max(1,(int)filesize($path));if($done&&trim($statement)!==''){fclose($h);$db->close();throw new RuntimeException('Database payload ends with an incomplete SQL statement.');}fclose($h);$db->close();$s['percent']=48+min(22,(int)floor(22*$s['sql_offset']/$size));$s['message']='Imported '.number_format($s['sql_statements']).' database statements';if($done){$s['phase']='prepare_urls';$s['percent']=71;$s['message']='Database restored; preparing safe URL updates';}$s['updated']=time();
    }
    private function prepareUrls(array&$s):void
    {
        $db=$this->connect($s['database']);$manifestUrl=rtrim((string)($s['manifest']['site']['url']??''),'/');$s['source_urls']=$this->sourceUrls($db,$s['prefix'],$manifestUrl);$tables=$db->get_col("SHOW FULL TABLES WHERE Table_type='BASE TABLE'");$s['rewrite_tables']=[];foreach($tables as$table)if(str_starts_with((string)$table,$s['prefix']))$s['rewrite_tables'][]=(string)$table;$db->close();$s['rewrite_url_index']=0;$s['rewrite_table_index']=0;$s['rewrite_offset']=0;$s['phase']='rewrite';$s['percent']=73;$s['message']='Updating serialized WordPress URLs safely';$s['updated']=time();
    }
    private function rewrite(array&$s):void
    {
        $urls=$s['source_urls'];$tables=$s['rewrite_tables'];while(isset($urls[$s['rewrite_url_index']])&&($urls[$s['rewrite_url_index']]===$s['url']||$urls[$s['rewrite_url_index']]==='')){$s['rewrite_url_index']++;$s['rewrite_table_index']=0;$s['rewrite_offset']=0;}if($s['rewrite_url_index']>=count($urls)){$db=$this->connect($s['database']);$this->setSiteUrls($db,$s['prefix'],$s['url']);$db->close();$s['phase']='files';$s['percent']=94;$s['message']='Database URLs updated; updating configuration files';$s['updated']=time();return;}if($s['rewrite_table_index']>=count($tables)){$s['rewrite_url_index']++;$s['rewrite_table_index']=0;$s['rewrite_offset']=0;$this->rewrite($s);return;}$db=$this->connect($s['database']);$processed=$this->rewriteBatch($db,$tables[$s['rewrite_table_index']],$urls[$s['rewrite_url_index']],$s['url'],(int)$s['rewrite_offset']);$db->close();if($processed<self::ROW_BATCH){$s['rewrite_table_index']++;$s['rewrite_offset']=0;}else$s['rewrite_offset']+=self::ROW_BATCH;$units=max(1,count($urls)*max(1,count($tables)));$done=$s['rewrite_url_index']*count($tables)+$s['rewrite_table_index'];$s['percent']=73+min(20,(int)floor(20*$done/$units));$s['message']='Updating WordPress content URLs · '.min($done,$units).' of '.$units.' table passes';$s['updated']=time();
    }
    private function textFiles(array&$s):void{foreach($s['source_urls']as$from)$this->replaceTextFiles($s['target'],$from,$s['url']);$s['phase']='verify';$s['percent']=96;$s['message']='URL updates complete; verifying restored site';$s['updated']=time();}
    private function verifyStep(array&$s):void{$db=$this->connect($s['database']);$this->verify($db,$s['target'],$s['prefix'],$s['url']);$db->close();$s['phase']='cleanup';$s['percent']=99;$s['message']='Recovery verified; removing temporary data';$s['updated']=time();}
    private function complete(array&$s):void{$this->remove($s['work']);$this->unlock();$s['status']='complete';$s['phase']='complete';$s['percent']=100;$s['message']='WordPress restore completed and verified';$s['completed']=time();$s['updated']=time();Fortis_WP_Log::write('restore_complete','success',$s['message'],['job'=>$s['id'],'target'=>$s['target'],'database'=>$s['database']]);}

    private function rewriteBatch(wpdb$db,string$table,string$from,string$to,int$offset):int
    {
        if($from===''||$from===$to)return 0;$safe=$this->identifier($table);$definitions=$db->get_results('SHOW COLUMNS FROM '.$safe,ARRAY_A);$columns=[];$primary=[];foreach($definitions as$column){$name=(string)$column['Field'];if($column['Key']==='PRI')$primary[]=$name;if(preg_match('/char|text|blob|json/i',(string)$column['Type']))$columns[]=$name;}if(!$primary||!$columns)return 0;$select=array_unique(array_merge($primary,$columns));$quoted=array_map(fn(string$n):string=>$this->identifier($n),$select);$rows=$db->get_results($db->prepare('SELECT '.implode(',',$quoted).' FROM '.$safe.' LIMIT %d OFFSET %d',self::ROW_BATCH,$offset),ARRAY_A);$count=0;foreach($rows as$row){$count++;$sets=[];$values=[];foreach($columns as$column){if(!is_string($row[$column]??null)||!str_contains($row[$column],$from))continue;$new=$this->replaceValue($row[$column],$from,$to);if($new!==$row[$column]){$sets[]=$this->identifier($column).'=%s';$values[]=$new;}}if(!$sets)continue;$where=[];foreach($primary as$column){$where[]=$this->identifier($column).'=%s';$values[]=(string)$row[$column];}$query=$db->prepare('UPDATE '.$safe.' SET '.implode(',',$sets).' WHERE '.implode(' AND ',$where).' LIMIT 1',...$values);if($query===null||$db->query($query)===false)throw new RuntimeException('Unable to apply safe URL replacement.');}return$count;
    }
    private function open(string$archive):ZipArchive{$zip=new ZipArchive();if($zip->open($archive,ZipArchive::CHECKCONS)!==true)throw new RuntimeException('Archive integrity check failed.');$zip->setPassword($this->key());return$zip;}
    private function extractEntry(ZipArchive$zip,string$name,string$destination,int$mode):void{$this->mkdir(dirname($destination));$in=$zip->getStream($name);if(!$in)throw new RuntimeException('Unable to decrypt '.$name.'. Check the recovery key.');$out=fopen($destination,'wb');if(!$out){fclose($in);throw new RuntimeException('Unable to write restored file '.$name.'.');}if(stream_copy_to_stream($in,$out)===false){fclose($in);fclose($out);throw new RuntimeException('Unable to restore '.$name.'.');}fclose($in);fclose($out);chmod($destination,$mode);}
    private function mkdir(string$path):void{if($path===''||$path==='.'||is_dir($path))return;if(!mkdir($path,0755,true)&&!is_dir($path))throw new RuntimeException('Unable to create a restored directory.');}
    private function destination(string$folder):array{$folder=sanitize_title($folder);if($folder===''||!preg_match('/^[a-z0-9][a-z0-9-]{2,63}$/',$folder))throw new RuntimeException('Enter a staging folder name using letters, numbers, and hyphens.');$uploads=wp_upload_dir();if(!empty($uploads['error']))throw new RuntimeException('WordPress could not determine a writable uploads location.');$root=wp_normalize_path(trailingslashit((string)$uploads['basedir']).'fortisrestore-staging');if(!is_dir($root)&&!wp_mkdir_p($root))throw new RuntimeException('Unable to create the WordPress staging area.');return['root'=>untrailingslashit($root),'path'=>trailingslashit($root).$folder,'url'=>trailingslashit((string)$uploads['baseurl']).'fortisrestore-staging/'.$folder];}
    private function safeTarget(string$target,string$root):string{$target=rtrim(str_replace('\\','/',trim($target)),'/');if($root===''||$target===''||str_contains($target,"\0")||preg_match('#(^|/)\.\.(/|$)#',$target))throw new RuntimeException('Invalid restore destination path.');if(!str_starts_with($target,$root.'/'))throw new RuntimeException('Restore target must stay inside the WordPress staging area.');$existing=realpath($target);$parent=realpath(dirname($target));if($existing!==false&&!str_starts_with($existing,$root.'/'))throw new RuntimeException('Restore target resolves outside the WordPress staging area.');if($existing===false&&($parent===false||($parent!==$root&&!str_starts_with($parent,$root.'/'))))throw new RuntimeException('The restore destination parent must already exist inside the staging area.');if(is_dir($target)&&(new FilesystemIterator($target))->valid())throw new RuntimeException('Restore target must be empty.');return$target;}
    private function emptyDatabase(string$name):wpdb{$db=$this->connect($name);$tables=$db->get_col('SHOW FULL TABLES');if($db->last_error!==''){$db->close();throw new RuntimeException('Unable to inspect the destination database.');}if(count($tables)!==0){$db->close();throw new RuntimeException('The destination database is not empty. FortisRestore will never overwrite an existing database.');}return$db;}
    private function connect(string$name):wpdb{$db=new wpdb(DB_USER,DB_PASSWORD,$name,DB_HOST);$db->hide_errors();if(!$db->ready||$db->last_error!=='')throw new RuntimeException('The destination database must already exist and be accessible with this WordPress database user.');return$db;}
    private function identifier(string$value):string{if(!preg_match('/^[A-Za-z0-9_$]+$/',$value))throw new RuntimeException('Unsafe database identifier detected.');return'`'.$value.'`';}
    private function validate(ZipArchive$zip,string$target):array{$total=0;$available=(int)(@disk_free_space(dirname($target))?:0);if($zip->numFiles>500000)throw new RuntimeException('Archive contains too many entries to restore safely.');for($i=0;$i<$zip->numFiles;$i++){$stat=$zip->statIndex($i);$name=str_replace('\\','/',(string)($stat['name']??''));if($name===''||str_starts_with($name,'/')||preg_match('#(^|/)\.\.(/|$)#',$name)||str_contains($name,"\0"))throw new RuntimeException('Archive contains an unsafe file path.');$total+=(int)($stat['size']??0);}if($available>0&&$total>(int)($available*.8))throw new RuntimeException('There is not enough disk space to restore this archive safely.');return['entries'=>$zip->numFiles,'bytes'=>$total];}
    private function sourceUrls(wpdb$db,string$prefix,string$manifestUrl):array{$urls=$manifestUrl===''?[]:[$manifestUrl];$table=$this->identifier($prefix.'options');$values=$db->get_col("SELECT option_value FROM {$table} WHERE option_name IN ('home','siteurl')");if($db->last_error!=='')throw new RuntimeException('Restored WordPress options table is unavailable.');foreach($values as$value){$value=rtrim((string)$value,'/');if($value!=='')$urls[]=$value;}return array_values(array_unique($urls));}
    private function setSiteUrls(wpdb$db,string$prefix,string$url):void{$table=$this->identifier($prefix.'options');$query=$db->prepare("UPDATE {$table} SET option_value=%s WHERE option_name IN ('home','siteurl')",$url);if($query===null||$db->query($query)===false)throw new RuntimeException('Unable to update the restored WordPress URL.');}
    private function replaceValue(string$value,string$from,string$to):string{if(!is_serialized($value))return str_replace($from,$to,$value);if(preg_match('/^[OC]:/',$value))return$value;$decoded=@unserialize($value,['allowed_classes'=>false]);if($decoded===false&&$value!=='b:0;')return$value;return serialize($this->replaceRecursive($decoded,$from,$to));}
    private function replaceRecursive(mixed$value,string$from,string$to):mixed{if(is_string($value))return str_replace($from,$to,$value);if(is_array($value))foreach($value as$key=>$item)$value[$key]=$this->replaceRecursive($item,$from,$to);return$value;}
    private function replaceTextFiles(string$target,string$from,string$to):void{if($from===''||$from===$to)return;foreach(['.htaccess','wp-config.php']as$name){$path=$target.'/'.$name;if(!is_file($path))continue;$data=(string)file_get_contents($path);$updated=str_replace($from,$to,$data);if($updated!==$data)file_put_contents($path,$updated,LOCK_EX);}}
    private function verify(wpdb$db,string$target,string$prefix,string$url):void{foreach(['index.php','wp-load.php','wp-config.php','wp-admin','wp-includes']as$required)if(!file_exists($target.'/'.$required))throw new RuntimeException('Restored site verification failed: missing '.$required.'.');$table=$this->identifier($prefix.'options');$count=$db->get_var($db->prepare("SELECT COUNT(*) FROM {$table} WHERE option_name IN ('home','siteurl') AND option_value=%s",$url));if((int)$count!==2)throw new RuntimeException('Restored site URL verification failed.');}
    private function cleanupFailure(array$s):void{try{$db=$this->connect($s['database']);$this->clearDatabase($db);$db->close();}catch(Throwable){}$this->remove($s['work']??'');$this->clearTarget($s['target']??'',(bool)($s['target_existed']??false));}
    private function clearDatabase(wpdb$db):void{$tables=$db->get_col("SHOW FULL TABLES WHERE Table_type='BASE TABLE'");if($tables){$db->query('SET FOREIGN_KEY_CHECKS=0');foreach($tables as$table)$db->query('DROP TABLE IF EXISTS '.$this->identifier((string)$table));$db->query('SET FOREIGN_KEY_CHECKS=1');}}
    private function clearTarget(string$target,bool$existed):void{if(!is_dir($target))return;$items=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($target,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);foreach($items as$item)$item->isDir()?@rmdir($item->getPathname()):@unlink($item->getPathname());if(!$existed)@rmdir($target);}
    private function remove(string$path):void{if(!is_dir($path))return;$items=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);foreach($items as$item)$item->isDir()?@rmdir($item->getPathname()):@unlink($item->getPathname());@rmdir($path);}
    private function key():string{$path=Fortis_WP_Backup_Job::home().'/.fortis-wp-master-key';if(!is_file($path))throw new RuntimeException('WordPress archive key is unavailable.');return trim((string)file_get_contents($path));}
    private function ensure():void{$dir=Fortis_WP_Backup_Job::work_dir();if(!is_dir($dir))mkdir($dir,0700,true);chmod($dir,0700);}
    private function statePath(string$id):string{if(!preg_match('/^[0-9]{14}-[a-f0-9]{8}$/',$id))throw new RuntimeException('Invalid restore job.');return Fortis_WP_Backup_Job::work_dir().'/restore-job-'.$id.'.json';}
    private function activePath():string{return Fortis_WP_Backup_Job::work_dir().'/restore-active';}
    private function save(array$s):void{$path=$this->statePath($s['id']);if(file_put_contents($path,json_encode($s,JSON_UNESCAPED_SLASHES),LOCK_EX)===false)throw new RuntimeException('Unable to save restore progress.');chmod($path,0600);}
    private function load(string$id):array{$s=json_decode((string)@file_get_contents($this->statePath($id)),true);if(!is_array($s))throw new RuntimeException('Restore job not found.');return$s;}
    private function unlock():void{if(is_file($this->activePath()))unlink($this->activePath());}
    private function publicState(array$s):array{return['id'=>$s['id'],'status'=>$s['status'],'phase'=>$s['phase'],'percent'=>(int)$s['percent'],'message'=>$s['message'],'archive'=>basename($s['archive']),'target'=>$s['target'],'database'=>$s['database'],'url'=>$s['url']??'','created'=>(int)$s['created'],'updated'=>(int)$s['updated']];}
}
