<?php
declare(strict_types=1);
const EF_TYPES=['school_grade','school_subject','school_lesson','college_field','college_subject','college_course','study_unit','study_chapter','study_notes','exam_profile','exam_syllabus','exam_pattern','eligibility_guide','fee_guide','registration_guide','admission_guide','practice_test','mock_exam','question_bank','daily_quiz','flashcard_set','worksheet','formula_sheet','vocabulary_set','study_guide','article','blog_post','education_news','exam_update','admission_update','education_job','pdf_material','youtube_video','video_playlist','faq_set','author_profile','reviewer_profile','course','tool'];
const EF_QUESTION_TYPES=['single_choice','multiple_choice','true_false','fill_blank','matching','passage'];
function ef_youtube_id(string$value):string{if(preg_match('/^[\w-]{11}$/',$value))return$value;if(preg_match('~(?:youtu\.be/|youtube\.com/(?:watch\?v=|embed/|shorts/))([\w-]{11})~',$value,$m))return$m[1];return'';}
function ef_zip_safe_path(string$name):bool{return$name!==''&&!str_starts_with($name,'/')&&!str_contains($name,'\\')&&!str_contains($name,'../')&&!str_contains($name,"\0");}
function ef_item_quality(array$item):array{$issues=[];$score=100;$visible=trim(preg_replace('/\s+/',' ',strip_tags($item['body']??'')));if(mb_strlen($visible)<600){$issues[]='Thin content: use at least 600 visible characters for production pages.';$score-=25;}if(preg_match('/lorem ipsum|dummy content|placeholder|replace this|as an ai language model|in conclusion.{0,20}in conclusion/i',$visible)){$issues[]='Placeholder or repetitive AI wording detected.';$score-=35;}if(empty($item['author'])||empty($item['reviewer'])){$issues[]='Named author and reviewer are required.';$score-=15;}if(!preg_match('~<h2[ >]~i',$item['body']??'')){$issues[]='At least one meaningful H2 section is required.';$score-=10;}if(preg_match('/\b(guaranteed|100% accurate|always|never changes)\b/i',$visible)&&empty($item['source_url'])){$issues[]='Unsupported absolute claim requires a primary source.';$score-=15;}if(preg_match('/copyrighted|official exam paper|leaked questions/i',$visible)){$issues[]='Copyright-risk wording requires manual review.';$score-=30;}return['score'=>max(0,$score),'issues'=>$issues];}
function ef_validate_question(array$q):array{$e=[];foreach(['id','type','prompt','answer','explanation'] as$k)if(!array_key_exists($k,$q)||$q[$k]==='')$e[]="Question missing $k";if(!in_array($q['type']??'',EF_QUESTION_TYPES,true))$e[]='Unsupported question type.';if(mb_strlen(strip_tags($q['explanation']??''))<30)$e[]='Question explanation is too short.';$values=[];foreach($q['options']??[] as$o)$values[]=(string)(is_array($o)?($o['value']??''):$o);$answers=is_array($q['answer']??null)?$q['answer']:[$q['answer']??null];if(in_array($q['type']??'',['single_choice','multiple_choice','true_false'],true)){if(count($values)<2)$e[]='Choice questions need at least two options.';foreach($answers as$a)if(!in_array((string)$a,$values,true))$e[]='Correct answer does not match an option value.';}if(($q['type']??'')==='matching'&&(!is_array($q['answer'])||count($q['answer'])<2))$e[]='Matching answer must contain at least two pairs.';if(($q['type']??'')==='passage'&&empty($q['passage']))$e[]='Passage question needs passage text.';return$e;}
function ef_validate_item(mixed$item):array{$e=[];if(!is_array($item))return['Item is not a JSON object.'];foreach(['id','type','slug','title','excerpt','body','meta_title','meta_description'] as$k)if(empty($item[$k]))$e[]="Missing $k";if(!preg_match('/^EF-USA-[0-9]{6}$/',$item['id']??''))$e[]='ID must match EF-USA-000001.';if(!in_array($item['type']??'',EF_TYPES,true))$e[]='Unsupported content type.';if(!preg_match('~^[a-z0-9]+(?:/[a-z0-9-]+)*$~',$item['slug']??''))$e[]='Slug must be a clean relative path.';$tl=mb_strlen($item['meta_title']??'');$dl=mb_strlen($item['meta_description']??'');if($tl<25||$tl>65)$e[]='Meta title must be 25–65 characters.';if($dl<70||$dl>170)$e[]='Meta description must be 70–170 characters.';if(!in_array($item['status']??'draft',['draft','needs_review','scheduled','published'],true))$e[]='Invalid publication status.';
 $changing=['exam_update','admission_update','education_news','education_job','eligibility_guide','fee_guide','registration_guide'];if(in_array($item['type']??'',$changing,true)&&(!filter_var($item['source_url']??'',FILTER_VALIDATE_URL)||parse_url($item['source_url'],PHP_URL_SCHEME)!=='https'))$e[]='Changing facts require a primary HTTPS source_url.';if(!empty($item['source_url'])&&parse_url($item['source_url'],PHP_URL_SCHEME)!=='https')$e[]='Source URL must use HTTPS.';
 if(in_array($item['type']??'',['practice_test','mock_exam','question_bank','daily_quiz'],true)){if(empty($item['data']['questions'])||!is_array($item['data']['questions']))$e[]='Practice content requires questions.';foreach($item['data']['questions']??[] as$i=>$q)foreach(ef_validate_question($q) as$x)$e[]='Question '.($i+1).': '.$x;}
 if(($item['type']??'')==='youtube_video'&&!ef_youtube_id($item['data']['youtube_id']??$item['data']['youtube_url']??''))$e[]='Valid YouTube ID or URL required.';if(($item['type']??'')==='pdf_material'){if(empty($item['data']['file'])||!preg_match('~^uploads/pdf/[a-zA-Z0-9_.-]+\.pdf$~',$item['data']['file']))$e[]='PDF file must be under uploads/pdf.';if(!isset($item['data']['download']))$e[]='PDF download permission must be explicit and defaults to false.';if(empty($item['data']['rights']))$e[]='PDF rights statement is required.';}
 if(($item['type']??'')==='blog_post'){if(!str_starts_with($item['slug']??'','blog/'))$e[]='Blog post slug must start with blog/.';if(empty($item['data']['primary_topic']))$e[]='Blog posts require data.primary_topic.';foreach($item['data']['faqs']??[] as$i=>$faq)if(mb_strlen(trim($faq['question']??''))<8||mb_strlen(trim($faq['answer']??''))<30)$e[]='Blog FAQ '.($i+1).' needs a useful question and answer.';if(!empty($item['data']['featured_image'])&&!preg_match('~^uploads/media/[a-zA-Z0-9_.-]+\.(webp|avif|jpe?g|png)$~i',$item['data']['featured_image']))$e[]='Blog featured image must be an imported image under uploads/media.';}
 if(($item['type']??'')==='tool'){$toolTypes=['grade_calculator','percentage_calculator','unit_converter','date_countdown','study_timer'];if(!str_starts_with($item['slug']??'','tools/'))$e[]='Tool slug must start with tools/.';if(!in_array($item['data']['tool_type']??'',$toolTypes,true))$e[]='Tool data.tool_type is not supported.';$fields=$item['data']['fields']??null;if(!is_array($fields)||!$fields)$e[]='Tools require configured input fields.';foreach($fields??[] as$i=>$field){if(!preg_match('/^[a-z][a-z0-9_]{0,30}$/',$field['name']??''))$e[]='Tool field '.($i+1).' has an invalid name.';if(!in_array($field['type']??'number',['number','date','select'],true))$e[]='Tool field '.($i+1).' has an invalid type.';}if(($item['data']['tool_type']??'')==='unit_converter'&&empty($item['data']['units']))$e[]='Unit converter requires data.units.';}
 preg_match_all('~href=["\']([^"\']+)~i',$item['body']??'',$links);foreach($links[1] as$link)if(!preg_match('~^(https://|/|#)~i',$link))$e[]="Invalid link: $link";$quality=ef_item_quality($item);foreach($quality['issues'] as$x)if($quality['score']<70)$e[]=$x;return array_values(array_unique($e));}
function ef_validate_package(string$zipPath):array{$errors=[];$warnings=[];$summary=[];$preview=[];$manifest=null;if(!class_exists('ZipArchive'))return['errors'=>['ZIP extension unavailable.'],'warnings'=>[],'summary'=>[],'preview'=>[],'manifest'=>null];$zip=new ZipArchive;if($zip->open($zipPath)!==true)return['errors'=>['Cannot open ZIP.'],'warnings'=>[],'summary'=>[],'preview'=>[],'manifest'=>null];if($zip->numFiles>5000)$errors[]='Package exceeds 5,000 files.';$total=0;$names=[];for($i=0;$i<$zip->numFiles;$i++){$st=$zip->statIndex($i);$name=$st['name'];$total+=(int)$st['size'];if(!ef_zip_safe_path($name))$errors[]="Unsafe path: $name";if(isset($names[strtolower($name)]))$errors[]="Duplicate ZIP path: $name";$names[strtolower($name)]=1;if(preg_match('~\.(php\d*|phtml|phar|cgi|pl|py|sh|exe|dll|so|htaccess)$~i',$name))$errors[]="Executable file blocked: $name";}if($total>100*1024*1024)$errors[]='Uncompressed package exceeds 100 MB.';$raw=$zip->getFromName('manifest.json');if($raw===false)$errors[]='manifest.json is required.';else{$manifest=json_decode($raw,true);if(!is_array($manifest))$errors[]='manifest.json is invalid JSON.';}
 if($manifest){foreach(['package_id','version','minimum_core','items'] as$k)if(!array_key_exists($k,$manifest))$errors[]="Manifest field required: $k";if(!preg_match('/^[A-Z0-9._-]{5,100}$/',$manifest['package_id']??''))$errors[]='Invalid package_id.';if(!preg_match('/^\d+\.\d+\.\d+$/',$manifest['version']??''))$errors[]='Version must use semantic format.';$core=trim((string)file_get_contents(EF_ROOT.'/VERSION'));if(version_compare($core,(string)($manifest['minimum_core']??'999'),'<'))$errors[]='Package requires core '.$manifest['minimum_core'].' or newer.';$seenIds=[];$seenSlugs=[];$packageSlugs=[];
  foreach($manifest['items']??[] as$ref){$file=is_array($ref)?($ref['file']??''):(string)$ref;if(!preg_match('~^items/[a-zA-Z0-9._-]+\.json$~',$file)){$errors[]="Invalid item reference: $file";continue;}$bytes=$zip->getFromName($file);if($bytes===false){$errors[]="Missing $file";continue;}$expected=is_array($ref)?($ref['sha256']??''):'';if(!$expected)$errors[]="$file requires a manifest SHA-256 checksum.";elseif(!preg_match('/^[a-f0-9]{64}$/i',$expected)||!hash_equals(strtolower($expected),hash('sha256',$bytes)))$errors[]="Checksum mismatch: $file";$item=json_decode($bytes,true);foreach(ef_validate_item($item) as$x)$errors[]="$file: $x";if(is_array($item)){$id=$item['id']??'';$slug=$item['slug']??'';if(isset($seenIds[$id]))$errors[]="Duplicate package ID: $id";if(isset($seenSlugs[$slug]))$errors[]="Duplicate package slug: $slug";$seenIds[$id]=1;$seenSlugs[$slug]=1;$packageSlugs[$slug]=1;$summary[$item['type']??'invalid']=($summary[$item['type']??'invalid']??0)+1;$existing=one('SELECT external_id,slug,content_hash FROM content WHERE external_id=? OR slug=?',[$id,$slug]);$quality=ef_item_quality($item);$preview[]=['id'=>$id,'type'=>$item['type']??'','slug'=>$slug,'action'=>$existing?'update':'create','existing_slug'=>$existing['slug']??null,'quality'=>$quality['score']];}}
  foreach($manifest['items']??[] as$ref){$file=is_array($ref)?$ref['file']:$ref;$item=json_decode($zip->getFromName($file),true);preg_match_all('~href=["\']/([^"\'#?]+)~i',$item['body']??'',$links);foreach($links[1] as$slug)if(!isset($packageSlugs[$slug])&&!one("SELECT id FROM content WHERE slug=? AND status='published'",[$slug]))$errors[]="$file: Broken internal link /$slug";}
  foreach($manifest['media']??[] as$m){$file=$m['file']??'';$target=$m['target']??'';if(!preg_match('~^media/[a-zA-Z0-9_.-]+\.(pdf|webp|avif|jpe?g|png)$~i',$file)||!preg_match('~^uploads/(pdf|media)/[a-zA-Z0-9_.-]+\.(pdf|webp|avif|jpe?g|png)$~i',$target)){$errors[]='Invalid media mapping.';continue;}$bytes=$zip->getFromName($file);if($bytes===false){$errors[]="Missing media: $file";continue;}$mime=(new finfo(FILEINFO_MIME_TYPE))->buffer($bytes);if(!in_array($mime,['application/pdf','image/webp','image/avif','image/jpeg','image/png'],true))$errors[]="Blocked MIME for $file: $mime";if(empty($m['rights']))$errors[]="$file requires a rights statement.";if(str_starts_with($mime,'image/')&&empty($m['alt']))$errors[]="$file requires useful alt text.";if(empty($m['sha256']))$errors[]="$file requires a SHA-256 checksum.";elseif(!hash_equals(strtolower($m['sha256']),hash('sha256',$bytes)))$errors[]="Checksum mismatch: $file";}
 }
 $zip->close();return compact('errors','warnings','summary','preview','manifest');}
function ef_import_package(string$zipPath,string$mode='skip'):array{if(!in_array($mode,['create','update','skip','replace'],true))throw new RuntimeException('Invalid import mode.');$v=ef_validate_package($zipPath);$m=$v['manifest'];if($v['errors']){ef_log_failed_import($m,$mode,$v['errors']);throw new RuntimeException(implode("\n",$v['errors']));}$zip=new ZipArchive;$zip->open($zipPath);$pdo=db();$created=0;$updated=0;$skipped=0;$logs=[];$written=[];$backups=[];$importId=0;try{$dupe=one('SELECT id FROM imports WHERE package_id=? AND version=? AND status="complete"',[$m['package_id'],$m['version']]);if($dupe)throw new RuntimeException('This exact package version is already installed.');q('INSERT INTO imports(package_id,version,mode,status,admin_id,created_at) VALUES(?,?,?,"running",?,NOW())',[$m['package_id'],$m['version'],$mode,admin_user()['id']??null]);$importId=(int)db()->lastInsertId();$pdo->beginTransaction();
 foreach($m['media']??[] as$x){$dest=EF_ROOT.'/'.$x['target'];if(!is_dir(dirname($dest))&&!mkdir(dirname($dest),0755,true))throw new RuntimeException('Cannot create media directory.');if(is_file($dest)&&!in_array($mode,['update','replace'],true)){$skipped++;continue;}$createdFile=!is_file($dest);$backup=null;if(!$createdFile){$backup='storage/backups/import-'.$importId.'-'.hash('sha256',$x['target']).'.bak';if(!copy($dest,EF_ROOT.'/'.$backup))throw new RuntimeException('Cannot back up media.');$backups[$dest]=EF_ROOT.'/'.$backup;}if(file_put_contents($dest,$zip->getFromName($x['file']),LOCK_EX)===false)throw new RuntimeException('Cannot install media.');$written[]=$dest;q('INSERT INTO import_files(import_id,target_path,backup_path,was_created) VALUES(?,?,?,?)',[$importId,$x['target'],$backup,(int)$createdFile]);$mime=(new finfo(FILEINFO_MIME_TYPE))->file($dest);$oldMedia=one('SELECT * FROM media WHERE path=?',[$x['target']]);q('INSERT INTO media(external_id,path,title,alt_text,mime,size,meta,created_at) VALUES(?,?,?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE title=VALUES(title),alt_text=VALUES(alt_text),mime=VALUES(mime),size=VALUES(size),meta=VALUES(meta)',[$x['id']??null,$x['target'],$x['title']??basename($dest),$x['alt']??'',$mime,filesize($dest),json_encode($x)]);$mediaRow=one('SELECT * FROM media WHERE path=?',[$x['target']]);ef_import_snapshot($importId,'media',(int)$mediaRow['id'],$oldMedia,!$oldMedia);}
 foreach($m['categories']??[] as$x){$old=one('SELECT * FROM categories WHERE external_id=? OR slug=?',[$x['id']??null,$x['slug']]);q('INSERT INTO categories(external_id,parent_id,name,slug,type,description,image,created_at,updated_at) VALUES(?,?,?,?,?,?,?,NOW(),NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name),type=VALUES(type),description=VALUES(description),image=VALUES(image),updated_at=NOW()',[$x['id']??null,null,$x['name'],$x['slug'],$x['type']??'general',$x['description']??'',$x['image']??null]);$row=one('SELECT * FROM categories WHERE external_id=? OR slug=?',[$x['id']??null,$x['slug']]);ef_import_snapshot($importId,'categories',(int)$row['id'],$old,!$old);}
 foreach($m['items'] as$ref){$file=is_array($ref)?$ref['file']:$ref;$item=json_decode($zip->getFromName($file),true);$existing=one('SELECT * FROM content WHERE external_id=? OR slug=?',[$item['id'],$item['slug']]);if($existing&&$mode==='skip'){$skipped++;$logs[]="Skipped {$item['id']}";continue;}if($existing&&$mode==='create')throw new RuntimeException("Duplicate item {$item['id']}");if(!$existing&&$mode==='update'){$skipped++;$logs[]="Update skipped missing {$item['id']}";continue;}$catId=null;if(!empty($item['category']))$catId=one('SELECT id FROM categories WHERE slug=?',[$item['category']])['id']??null;$parentId=null;if(!empty($item['parent']))$parentId=one('SELECT id FROM content WHERE external_id=? OR slug=?',[$item['parent'],$item['parent']])['id']??null;$quality=ef_item_quality($item);$status=$item['status']??'draft';if($status==='published'&&(!(bool)($m['allow_auto_publish']??false)||$quality['score']<80))$status='needs_review';$noindex=$status==='published'?(int)(bool)($item['noindex']??false):1;$hash=hash('sha256',clean_html($item['body']).'|'.mb_strtolower($item['title']));$dup=one('SELECT external_id FROM content WHERE content_hash=? AND external_id<>?',[$hash,$item['id']]);if($dup)throw new RuntimeException("Duplicate content matches {$dup['external_id']}");$slug=$existing?$existing['slug']:$item['slug'];$vals=[$item['id'],$item['type'],$catId,$parentId,$slug,$item['title'],$item['excerpt'],clean_html($item['body']),json_encode($item['data']??[],JSON_UNESCAPED_SLASHES),$item['meta_title'],$item['meta_description'],$item['canonical_url']??null,$status,$noindex,(int)!empty($item['featured']),(int)!empty($item['ad_free']),$item['source_url']??null,$item['author'],$item['reviewer'],$hash,$quality['score'],$item['published_at']??($status==='published'?date('Y-m-d H:i:s'):null),$item['reviewed_at']??null];if($existing){q('INSERT INTO import_snapshots(import_id,entity_type,content_id,previous_data,was_created) VALUES(?,"content",?,?,0)',[$importId,$existing['id'],json_encode($existing)]);q('UPDATE content SET external_id=?,type=?,category_id=?,parent_id=?,slug=?,title=?,excerpt=?,body=?,data=?,meta_title=?,meta_description=?,canonical_url=?,status=?,noindex=?,featured=?,ad_free=?,source_url=?,author=?,reviewer=?,content_hash=?,quality_score=?,published_at=?,reviewed_at=?,updated_at=NOW() WHERE id=?',[...$vals,$existing['id']]);$updated++;$id=(int)$existing['id'];}else{q('INSERT INTO content(external_id,type,category_id,parent_id,slug,title,excerpt,body,data,meta_title,meta_description,canonical_url,status,noindex,featured,ad_free,source_url,author,reviewer,content_hash,quality_score,published_at,reviewed_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW())',$vals);$id=(int)db()->lastInsertId();q('INSERT INTO import_snapshots(import_id,entity_type,content_id,was_created) VALUES(?,"content",?,1)',[$importId,$id]);$created++;}}
 foreach($m['sections']??[] as$x){$cat=empty($x['category'])?null:one('SELECT id FROM categories WHERE slug=?',[$x['category']]);$old=empty($x['id'])?null:one('SELECT * FROM sections WHERE external_id=?',[$x['id']]);q('INSERT INTO sections(external_id,title,category_id,content_types,layout,item_limit,position,enabled) VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE title=VALUES(title),category_id=VALUES(category_id),content_types=VALUES(content_types),layout=VALUES(layout),item_limit=VALUES(item_limit),position=VALUES(position),enabled=VALUES(enabled)',[$x['id']??null,$x['title'],$cat['id']??0,json_encode($x['content_types']??[]),in_array($x['layout']??'cards',['cards','compact','list'],true)?$x['layout']:'cards',min(12,max(1,(int)($x['item_limit']??6))),(int)($x['position']??0),(int)($x['enabled']??1)]);$row=empty($x['id'])?one('SELECT * FROM sections ORDER BY id DESC LIMIT 1'):one('SELECT * FROM sections WHERE external_id=?',[$x['id']]);ef_import_snapshot($importId,'sections',(int)$row['id'],$old,!$old);}
 foreach($m['menus']??[] as$x){$old=empty($x['id'])?null:one('SELECT * FROM menus WHERE external_id=?',[$x['id']]);q('INSERT INTO menus(external_id,label,url,location,position,enabled) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE label=VALUES(label),url=VALUES(url),location=VALUES(location),position=VALUES(position),enabled=VALUES(enabled)',[$x['id']??null,$x['label'],$x['url'],$x['location']??'header',(int)($x['position']??0),(int)($x['enabled']??1)]);$row=empty($x['id'])?one('SELECT * FROM menus ORDER BY id DESC LIMIT 1'):one('SELECT * FROM menus WHERE external_id=?',[$x['id']]);ef_import_snapshot($importId,'menus',(int)$row['id'],$old,!$old);}
 foreach($m['redirects']??[] as$x){$source=trim($x['source'],'/');$old=one('SELECT * FROM redirects WHERE external_id=? OR source=?',[$x['id']??null,$source]);q('INSERT INTO redirects(external_id,source,target,status_code,enabled) VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE target=VALUES(target),status_code=VALUES(status_code),enabled=VALUES(enabled)',[$x['id']??null,$source,$x['target'],in_array((int)($x['status_code']??301),[301,302,307,308],true)?(int)$x['status_code']:301,(int)($x['enabled']??1)]);$row=one('SELECT * FROM redirects WHERE external_id=? OR source=?',[$x['id']??null,$source]);ef_import_snapshot($importId,'redirects',(int)$row['id'],$old,!$old);}
 foreach($m['feeds']??[] as$x){$host=parse_url($x['url']??'',PHP_URL_HOST);if(!$host||parse_url($x['url'],PHP_URL_SCHEME)!=='https')throw new RuntimeException('Feed must use a valid HTTPS URL.');$cat=empty($x['category'])?null:one('SELECT id FROM categories WHERE slug=?',[$x['category']]);$old=one('SELECT * FROM feeds WHERE external_id=? OR url=?',[$x['id']??null,$x['url']]);q('INSERT INTO feeds(external_id,name,url,allowed_host,category_id,kind,fetch_interval,item_limit,enabled) VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE name=VALUES(name),allowed_host=VALUES(allowed_host),category_id=VALUES(category_id),kind=VALUES(kind),fetch_interval=VALUES(fetch_interval),item_limit=VALUES(item_limit),enabled=VALUES(enabled)',[$x['id']??null,$x['name'],$x['url'],$host,$cat['id']??null,$x['kind']??'education_news',max(15,(int)($x['fetch_interval']??60)),min(50,max(1,(int)($x['item_limit']??10))),(int)($x['enabled']??1)]);$row=one('SELECT * FROM feeds WHERE external_id=? OR url=?',[$x['id']??null,$x['url']]);ef_import_snapshot($importId,'feeds',(int)$row['id'],$old,!$old);}
 $allowedSettings=['site_description','theme_color','robots_extra','default_appearance'];foreach($m['seo_updates']??[] as$k=>$value)if(in_array($k,$allowedSettings,true)){$old=one('SELECT `key`,value FROM settings WHERE `key`=?',[$k]);q('INSERT INTO settings(`key`,value) VALUES(?,?) ON DUPLICATE KEY UPDATE value=VALUES(value)',[$k,(string)$value]);ef_import_snapshot($importId,'settings',0,$old??['key'=>$k,'value'=>null],!$old);}q('UPDATE imports SET status="complete",items_created=?,items_updated=?,items_skipped=?,log=?,finished_at=NOW() WHERE id=?',[$created,$updated,$skipped,implode("\n",$logs),$importId]);$pdo->commit();ef_cache_clear();audit('import.complete',['package'=>$m['package_id'],'created'=>$created,'updated'=>$updated,'skipped'=>$skipped]);return compact('created','updated','skipped','logs','importId');
 }catch(Throwable$e){if($pdo->inTransaction())$pdo->rollBack();foreach($written as$f){if(isset($backups[$f]))copy($backups[$f],$f);else@unlink($f);}if($importId)q('UPDATE imports SET status="failed",errors=?,finished_at=NOW() WHERE id=?',[substr($e->getMessage(),0,10000),$importId]);throw$e;}finally{$zip->close();}}
function ef_log_failed_import(?array$m,string$mode,array$errors):void{try{q('INSERT INTO imports(package_id,version,mode,status,errors,admin_id,created_at,finished_at) VALUES(?,?,?,"validation_failed",?,?,NOW(),NOW())',[$m['package_id']??'UNKNOWN',$m['version']??'0.0.0',$mode,implode("\n",$errors),admin_user()['id']??null]);}catch(Throwable){}}
function ef_import_snapshot(int$importId,string$type,int$id,?array$previous,bool$created):void{q('INSERT INTO import_snapshots(import_id,entity_type,content_id,previous_data,was_created) VALUES(?,?,?,?,?)',[$importId,$type,$id,$previous?json_encode($previous,JSON_UNESCAPED_SLASHES):null,(int)$created]);}
function ef_restore_row(string$table,int$id,array$row):void{$allowed=['content'=>['external_id','type','category_id','parent_id','slug','title','excerpt','body','data','meta_title','meta_description','canonical_url','status','noindex','featured','ad_free','source_url','author','reviewer','content_hash','quality_score','views','published_at','reviewed_at','created_at','updated_at'],'categories'=>['external_id','parent_id','name','slug','type','description','image','created_at','updated_at'],'sections'=>['external_id','title','category_id','content_types','layout','item_limit','position','enabled'],'menus'=>['external_id','label','url','location','position','enabled'],'redirects'=>['external_id','source','target','status_code','enabled','hits'],'feeds'=>['external_id','name','url','allowed_host','category_id','kind','fetch_interval','enabled','item_limit','next_run','last_run','last_status','health','failure_count'],'media'=>['external_id','path','title','alt_text','mime','size','meta','created_at']];if(!isset($allowed[$table]))throw new RuntimeException('Unsupported rollback entity.');$sets=[];$values=[];foreach($allowed[$table] as$column){$sets[]='`'.$column.'`=?';$values[]=$row[$column]??null;}$values[]=$id;q('UPDATE `'.$table.'` SET '.implode(',',$sets).' WHERE id=?',$values);}
function ef_rollback_import(int$id):void{$record=one('SELECT * FROM imports WHERE id=?',[$id]);if(!$record||$record['status']!=='complete')throw new RuntimeException('Only a completed import can be rolled back once.');$pdo=db();$pdo->beginTransaction();try{foreach(all('SELECT * FROM import_snapshots WHERE import_id=? ORDER BY (entity_type="content") DESC,id DESC',[$id]) as$s){$type=$s['entity_type'];$previous=json_decode($s['previous_data']??'null',true);if($type==='settings'){if($s['was_created'])q('DELETE FROM settings WHERE `key`=?',[$previous['key']??'']);elseif($previous)q('INSERT INTO settings(`key`,value) VALUES(?,?) ON DUPLICATE KEY UPDATE value=VALUES(value)',[$previous['key'],$previous['value']]);continue;}if(!in_array($type,['content','categories','sections','menus','redirects','feeds','media'],true))continue;if($s['was_created'])q('DELETE FROM `'.$type.'` WHERE id=?',[$s['content_id']]);elseif(is_array($previous))ef_restore_row($type,(int)$s['content_id'],$previous);}foreach(all('SELECT * FROM import_files WHERE import_id=? ORDER BY id DESC',[$id]) as$f){$target=EF_ROOT.'/'.$f['target_path'];if($f['was_created']){if(is_file($target))unlink($target);}elseif($f['backup_path']&&is_file(EF_ROOT.'/'.$f['backup_path'])){if(!copy(EF_ROOT.'/'.$f['backup_path'],$target))throw new RuntimeException('Could not restore imported file.');}}q('UPDATE imports SET status="rolled_back",finished_at=NOW() WHERE id=?',[$id]);$pdo->commit();ef_cache_clear();audit('import.rollback',['id'=>$id]);}catch(Throwable$e){if($pdo->inTransaction())$pdo->rollBack();throw$e;}}
