<?php
declare(strict_types=1);

function ef_public_feed_host(string $host): bool
{
    if ($host === '' || $host === 'localhost' || str_ends_with($host, '.local')) return false;
    $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: [];
    if (!$records) return false;
    foreach ($records as $record) {
        $ip = $record['ip'] ?? $record['ipv6'] ?? '';
        if (!$ip || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) return false;
    }
    return true;
}

function ef_fetch_feeds(bool $healthOnly=false, ?int $onlyId=null): array
{
    $result=['feeds'=>0,'items'=>0,'errors'=>[]];
    $where="enabled=1 AND (next_run IS NULL OR next_run<=NOW())";$params=[];
    if($onlyId){$where='id=? AND enabled=1';$params[]=$onlyId;}
    $rows=all("SELECT * FROM feeds WHERE $where",$params);
    foreach($rows as$f){
        try{
            $host=strtolower((string)parse_url($f['url'],PHP_URL_HOST));
            if(parse_url($f['url'],PHP_URL_SCHEME)!=='https'||!$host||strcasecmp($host,$f['allowed_host'])!==0||!ef_public_feed_host($host)) throw new RuntimeException('Feed URL is outside its allowlisted public HTTPS host.');
            $ctx=stream_context_create(['http'=>['timeout'=>12,'user_agent'=>setting('feed_user_agent','Exams.fit Feed Reader/3.0'),'max_redirects'=>0,'follow_location'=>0,'ignore_errors'=>false],'ssl'=>['verify_peer'=>true,'verify_peer_name'=>true]]);
            $raw=@file_get_contents($f['url'],false,$ctx,0,2*1024*1024);if($raw===false)throw new RuntimeException('Fetch failed or source redirected.');
            libxml_use_internal_errors(true);$xml=simplexml_load_string($raw,'SimpleXMLElement',LIBXML_NONET|LIBXML_NOCDATA);if(!$xml)throw new RuntimeException('Invalid RSS/Atom XML.');
            $nodes=isset($xml->channel->item)?$xml->channel->item:($xml->entry??[]);$count=0;
            if(!$healthOnly)foreach($nodes as$i){
                if($count>=(int)$f['item_limit'])break;
                $title=trim((string)$i->title);$link=trim((string)($i->link['href']??$i->link));$desc=trim(strip_tags((string)($i->description??$i->summary??'')));$date=(string)($i->pubDate??$i->updated??$i->published??'now');
                if(!$title||!filter_var($link,FILTER_VALIDATE_URL)||parse_url($link,PHP_URL_SCHEME)!=='https'||strcasecmp((string)parse_url($link,PHP_URL_HOST),$host)!==0)continue;
                $hash=substr(hash('sha256',$link),0,12);if(one('SELECT id FROM content WHERE external_id=?',['EF-FEED-'.$hash]))continue;
                $slug='updates/'.trim((string)preg_replace('/[^a-z0-9]+/','-',strtolower(mb_substr($title,0,90))),'-').'-'.$hash;$excerpt=mb_substr($desc?:'Read the attributed update from the official source.',0,300);
                $body='<p>'.e(mb_substr($desc,0,700)).'</p><p><a rel="nofollow noopener" href="'.e($link).'">Read the original update at '.e($f['name']).'</a></p>';
                q('INSERT INTO content(external_id,type,category_id,slug,title,excerpt,body,data,meta_title,meta_description,status,noindex,featured,source_url,author,reviewer,content_hash,quality_score,published_at,reviewed_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW())',[
                    'EF-FEED-'.$hash,$f['kind']?:'education_news',$f['category_id'],$slug,mb_substr($title,0,250),$excerpt,$body,'{}',mb_substr($title,0,65),mb_substr($excerpt,0,165),'published',1,0,$link,$f['name'],'Feed-only item — needs editorial review',hash('sha256',$title.$link),40,date('Y-m-d H:i:s',strtotime($date)?:time()),null
                ]);
                $count++;$result['items']++;
            }
            q('UPDATE feeds SET last_run=NOW(),next_run=DATE_ADD(NOW(),INTERVAL ? MINUTE),last_status=?,health="healthy",failure_count=0 WHERE id=?',[(int)$f['fetch_interval'],"OK: $count new",$f['id']]);$result['feeds']++;
        }catch(Throwable$e){q('UPDATE feeds SET last_run=NOW(),next_run=DATE_ADD(NOW(),INTERVAL ? MINUTE),last_status=?,health="error",failure_count=failure_count+1 WHERE id=?',[(int)$f['fetch_interval'],'Error: '.substr($e->getMessage(),0,200),$f['id']]);$result['errors'][]=$f['name'].': '.$e->getMessage();}
    }
    return$result;
}

function ef_run_cron_job(string $job): array
{
    $allowed=['all','publish','queue','feeds','sitemap','cache','analytics','links','health','optimize','backup','freshness'];if(!in_array($job,$allowed,true))throw new RuntimeException('Unknown cron job.');
    $lockPath=EF_ROOT.'/storage/cron-'.$job.'.lock';$fp=fopen($lockPath,'c+');if(!$fp||!flock($fp,LOCK_EX|LOCK_NB))throw new RuntimeException('This cron job is already running.');
    $started=microtime(true);$messages=[];$status='ok';
    try{
        if(in_array($job,['all','publish'],true)){$n=q("UPDATE content SET status='published',noindex=0,updated_at=NOW() WHERE status='scheduled' AND published_at<=NOW()")->rowCount();$messages[]="$n scheduled pages published";}
        if(in_array($job,['all','queue'],true)){$r=ef_process_import_queue();$messages[]="queue {$r['done']} complete, {$r['failed']} failed";}
        if(in_array($job,['all','feeds'],true)){$r=ef_fetch_feeds();$messages[]="feeds {$r['feeds']}, items {$r['items']}, errors ".count($r['errors']);}
        if(in_array($job,['all','sitemap'],true)){file_put_contents(EF_ROOT.'/storage/cache/sitemap.touch',date(DATE_ATOM),LOCK_EX);$messages[]='sitemap state refreshed';}
        if(in_array($job,['all','cache'],true))$messages[]='cache files cleared '.ef_cache_clear();
        if(in_array($job,['all','analytics'],true)){ef_analytics_aggregate();$days=max(30,min(730,(int)setting('analytics_retention_days','180')));q("DELETE FROM analytics WHERE day<DATE_SUB(CURDATE(),INTERVAL $days DAY)");$messages[]='analytics aggregated and retention applied';}
        if($job==='links'){$r=ef_broken_link_scan();$messages[]="links {$r['checked']}, broken {$r['broken']}";}
        if($job==='health'){$r=ef_fetch_feeds(true);$messages[]="feed health checked {$r['feeds']}, errors ".count($r['errors']);}
        if($job==='backup')$messages[]='backup '.ef_backup_create();
        if($job==='optimize'){foreach(all('SHOW TABLES') as$t){$name=array_values($t)[0];q('OPTIMIZE TABLE `'.$name.'`');}$messages[]='database optimized';}
        if($job==='freshness'){$n=(int)(one("SELECT COUNT(*) n FROM content WHERE status='published' AND updated_at<DATE_SUB(NOW(),INTERVAL 365 DAY)")['n']??0);$messages[]="$n published pages need freshness review";}
    }catch(Throwable$e){$status='error';$messages[]=$e->getMessage();}
    finally{flock($fp,LOCK_UN);fclose($fp);}
    $duration=(int)round((microtime(true)-$started)*1000);q('INSERT INTO cron_log(job,status,message,ran_at,duration_ms) VALUES(?,?,?,NOW(),?)',[$job,$status,implode('; ',$messages),$duration]);
    return compact('job','status','messages','duration');
}

function ef_cron(): void
{
    global$config;if(!hash_equals($config['cron_token']??'',$_GET['token']??'')){http_response_code(403);exit('Forbidden');}
    try{$result=ef_run_cron_job((string)($_GET['job']??'all'));header('Content-Type:application/json');echo json_encode($result);}catch(RuntimeException$e){http_response_code(str_contains($e->getMessage(),'already running')?409:400);echo e($e->getMessage());}
}
