<?php
/**
 * DSC Football Hub analytics autopilot — run nightly with cPanel Cron Jobs.
 * Unlike the public hub-data.php connector, this file is CLI ONLY.
 * It retrieves completed Oregon games via ESPN's public JSON schedule and
 * play-by-play, computes documented play-by-play estimates, and atomically
 * merges them into dsc-analytics-2026.json. Published hand-reviewed records
 * are never overwritten. Fails closed when feeds or play records are lacking.
 *
 * As an alternative, the existing official GoDucks feed continues supplying
 * team stats and completed game cards through hub-data.php.
 *
 * ESPN's endpoints are undocumented and could change; log each run. The
 * original DSC opponent-adjusted FBS baseline was never saved in hub-data.php.
 * Therefore this automatic estimate is explicitly labeled DSC-PBP-v1, NOT
 * presented as an exact recovery of the historical proprietary model.
 */
if (PHP_SAPI !== 'cli') { http_response_code(403); exit('CLI only'); }
ini_set('display_errors', 'stderr');
$root = __DIR__;
$dataFile = $root . '/dsc-analytics-2026.json';
$season = 2026;
$oregonId = '2483';
$forceDryRun = in_array('--dry-run', $argv, true); // never writes the JSON in test mode

function log_line($msg) { echo '[' . gmdate('c') . '] ' . $msg . "\n"; }
function fetch_json($url) {
    // Avoid fetching arbitrary URLs; the feed hosts are fixed.
    $parsed = parse_url($url);
    if (($parsed['scheme'] ?? '') !== 'https' || ($parsed['host'] ?? '') !== 'site.api.espn.com') return null;
    if (function_exists('curl_init')) {
        $ch=curl_init($url);
        curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_CONNECTTIMEOUT=>10,CURLOPT_TIMEOUT=>35,CURLOPT_USERAGENT=>'DSC-FootballAnalytics/1.0',CURLOPT_FOLLOWLOCATION=>false]);
        $response=curl_exec($ch);$status=(int)curl_getinfo($ch,CURLINFO_RESPONSE_CODE);curl_close($ch);
        if ($status!==200 || $response===false)return null;
    } else {
        $response=@file_get_contents($url,false,stream_context_create(['http'=>['timeout'=>35,'header'=>"User-Agent: DSC-FootballAnalytics/1.0\r\n"]]));
        if ($response===false)return null;
    }
    $data=json_decode($response,true);
    return is_array($data)?$data:null;
}
function num($x,$fallback=0) {return is_numeric($x)?(float)$x:$fallback;}
function clip($x,$a=0,$b=1) {return max($a,min($b,$x));}
function ratio($a,$b,$default=.5) {return $b>0?$a/$b:$default;}
function model_rows($m) {
    $out=[];
    foreach($m as $k=>$v)$out[]=['metric'=>$k,'value'=>(string)$v];
    return $out;
}
function extract_plays($summary,$oreId,$oppId) {
    $src=$summary['plays']??[];
    if (!$src || !is_array($src)) {
        $src=[];
        foreach(['previous','current'] as $kind) {
            foreach (($summary['drives'][$kind]??[]) as $drive) {
                if (!is_array($drive))continue;
                foreach(($drive['plays']??[]) as $p) {
                    $p['_drive_id']=$drive['id']??($drive['displayResult']??'');$src[]=$p;
                }
            }
        }
    }
    $out=[];$seen=[];
    foreach($src as $p) {
        if (!is_array($p))continue;
        $id=(string)($p['id']??'');if($id!=='' && isset($seen[$id]))continue;$seen[$id]=true;
        $text=(string)($p['text']??'');
        if ($text==='' || preg_match('/NO PLAY|no play|timeout|end of (quarter|game|half)|start of|false start/i',$text))continue;
        $team=(string)($p['team']['id']??$p['start']['team']['id']??$p['possession']['id']??'');
        if($team!==$oreId && $team!==$oppId)continue;
        $type=(string)($p['type']['text']??'');
        if (preg_match('/punt|kickoff|field goal|extra point|two.point|penalty|timeout|kneel/i',$text.' '.$type))continue;
        $kind=null;
        if(preg_match('/pass|sack|spike/i',$type.' '.$text))$kind='pass';
        elseif(preg_match('/rush|run|scramble|keeper/i',$type.' '.$text))$kind='run';
        if(!$kind)continue;
        $down=(int)($p['start']['down']??0);
        $distance=(int)($p['start']['distance']??0);
        if(!$down && preg_match('/\b([1234])(?:st|nd|rd|th) and (\d+)/i',$text,$m)){$down=(int)$m[1];$distance=(int)$m[2];}
        if($down<1 || $down>4 || $distance<=0 || $distance>99)continue;
        $yards=num($p['statYardage']??null,0);
        $out[]=['team'=>$team,'kind'=>$kind,'down'=>$down,'distance'=>$distance,'yards'=>$yards,
            'quarter'=>(int)($p['period']['number']??0),
            'drive'=>(string)($p['_drive_id']??$p['drive']['id']??''),
            'description'=>$text,
            'first'=>(bool)preg_match('/first down|1st down|touchdown/i',$text),
            'sack'=>(bool)preg_match('/sacked|sack/i',$text),
            'breakup'=>(bool)preg_match('/broken up|pass breakup|deflected/i',$text),
            'hurry'=>(bool)preg_match('/hurried|hurry|pressure/i',$text),
            'interception'=>(bool)preg_match('/intercepted/i',$text),
            'turnover'=>(bool)preg_match('/intercepted|fumble.*recovered by/i',$text)];
    }
    return $out;
}
function team_stats($plays) {
    $r=['total'=>count($plays),'pass'=>0,'run'=>0,'sacks'=>0,'breakups'=>0,'hurries'=>0,'interceptions'=>0,'turnovers'=>0,
        'neg_run'=>0,'no_gain_run'=>0,'nonpositive'=>0,'third_attempts'=>0,'third_conversions'=>0,'fourth_attempts'=>0,'fourth_conversions'=>0];
    foreach($plays as $p){
        $r[$p['kind']]++;
        $r['sacks']+=(int)$p['sack'];$r['breakups']+=(int)$p['breakup'];$r['hurries']+=(int)$p['hurry'];
        $r['interceptions']+=(int)$p['interception'];$r['turnovers']+=(int)$p['turnover'];
        if($p['kind']==='run'){$r['neg_run']+=(int)($p['yards']<0);$r['no_gain_run']+=(int)($p['yards']<=0);}
        $r['nonpositive']+=(int)($p['yards']<=0);
        if($p['down']==3){$r['third_attempts']++;$r['third_conversions']+=(int)$p['first'];}
        if($p['down']==4){$r['fourth_attempts']++;$r['fourth_conversions']+=(int)$p['first'];}
    }
    $r['havoc']=$r['sacks']+$r['neg_run']+$r['breakups']+$r['interceptions'];
    return $r;
}
function auto_scores($off,$def) {
    $o=team_stats($off);$d=team_stats($def);
    if($o['total']<25 || $d['total']<25 || $o['third_attempts']<4 || $d['third_attempts']<4)return null;
    $passRate=ratio($o['pass'],$o['total']);
    $thirdLong=array_values(array_filter($off,fn($p)=>$p['down']==3 && $p['distance']>=4));
    $longPass=ratio(count(array_filter($thirdLong,fn($p)=>$p['kind']==='pass')),count($thirdLong));
    $first=array_values(array_filter($off,fn($p)=>$p['down']==1));
    $firstPass=ratio(count(array_filter($first,fn($p)=>$p['kind']==='pass')),count($first));
    $paired=0;$passPass=0;$passPredecessors=0;
    for($i=1;$i<count($off);$i++) {
        $a=$off[$i-1];$b=$off[$i];
        if($a['drive']!=='' && $b['drive']!=='' && $a['drive']!==$b['drive'])continue;
        $paired++;
        if($a['kind']==='pass'){$passPredecessors++;if($b['kind']==='pass')$passPass++;}
    }
    $repeat=ratio($passPass,$passPredecessors);
    // A stable play-by-play operational proxy, NOT original opponent-adjusted FBS Z-scores.
    $ops=(int)round(clip(23+18*$passRate+24*$longPass+15*$repeat+10*(2*abs($firstPass-.5)),0,100));
    $raw=[
        'havoc'=>clip(ratio($d['havoc'],$d['total'],0)/.22),
        'pressure'=>clip(ratio($d['sacks']+$d['hurries'],$d['pass'],0)/.20),
        'negative'=>clip(ratio($d['nonpositive'],$d['total'],0)/.45),
        'run'=>clip(ratio($d['no_gain_run'],$d['run'],0)/.50),
        'coverage'=>clip(ratio($d['breakups']+$d['interceptions'],$d['pass'],0)/.20),
        'turnover'=>clip(ratio($d['turnovers'],max(1,ceil($d['total']/6)),0)/.17),
        'stop'=>clip((1-ratio($d['third_conversions'],$d['third_attempts'],0))/.75),
        'money'=>clip((1-ratio($d['third_conversions'],$d['third_attempts'],0))/.80)
    ];
    $weights=['havoc'=>.20,'pressure'=>.17,'negative'=>.15,'run'=>.11,'coverage'=>.11,'turnover'=>.09,'stop'=>.09,'money'=>.08];
    $weighted=0;foreach($weights as $k=>$w)$weighted+=$w*$raw[$k];
    $dcs=(int)round(clip(18+55*$weighted,0,100));
    return ['ops'=>$ops,'dcs'=>$dcs,'offense'=>$o,'opponent'=>$d,'longPass'=>$longPass,'repeat'=>$repeat];
}

$before=@file_get_contents($dataFile);
$db=json_decode((string)$before,true);
if(!is_array($db)||!isset($db['games'])||!is_array($db['games'])){log_line('ERROR: original data JSON missing/invalid; refusing to replace it');exit(2);}
$url='https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams/'.$oregonId.'/schedule?season='.$season;
$schedule=fetch_json($url);
if(!is_array($schedule)||!isset($schedule['events'])||!is_array($schedule['events'])){log_line('ERROR: ESPN schedule unavailable; existing Hub data preserved');exit(3);}
// ESPN may put the game status on the event OR its competition.
// Read both so a completed game is not silently skipped.
function dsc_event_is_final($event) {
    $atEvent = $event['status']['type']['completed'] ?? null;
    $atCompetition = $event['competitions'][0]['status']['type']['completed'] ?? null;
    return $atEvent === true || $atCompetition === true;
}
$processed=0;$skipped=0;$added=0;
$scheduled=count($schedule['events']);$completed=0;$unrecognized=0;$attempted=0;
foreach($schedule['events'] as $event){
    if(!is_array($event)||!dsc_event_is_final($event))continue;
    $completed++;
    $competition=$event['competitions'][0]??[];$opponent=null;$oregon=null;
    foreach(($competition['competitors']??[]) as $c){
        if((string)($c['team']['id']??'')===$oregonId)$oregon=$c;else $opponent=$c;
    }
    if(!$oregon || !$opponent){$unrecognized++;continue;}
    $name=(string)($opponent['team']['displayName']??$opponent['team']['name']??'');
    $name=preg_replace('/\b(Cornhuskers|Bruins|Trojans|Cowboys|Broncos|Vikings)\b/i','',$name);
    $slug=strtolower(trim(preg_replace('/[^a-z0-9]+/i','-',$name),'-'));
    $aliases=['southern-california'=>'usc','ucla'=>'ucla','oregon-state'=>'oregon-state','nebraska'=>'nebraska','portland-state'=>'portland-state','oklahoma-state'=>'oklahoma-state','boise-state'=>'boise-state'];
    $slug=$aliases[$slug]??$slug;
    if($slug==='' || strlen($slug)>100)continue;
    $existing=$db['games'][$slug]??null;
    if(is_array($existing)&&is_numeric($existing['ops']??null)&&is_numeric($existing['dcs']??null)){$skipped++;continue;}
    $id=(string)($event['id']??'');if(!preg_match('/^\d+$/',$id))continue;
    $attempted++;
    $summary=fetch_json('https://site.api.espn.com/apis/site/v2/sports/football/college-football/summary?event='.$id);
    if(!$summary){log_line('WARN: summary unavailable event='.$id);continue;}
    $oppId=(string)$opponent['team']['id'];$pbp=extract_plays($summary,$oregonId,$oppId);
    $orePlays=array_values(array_filter($pbp,fn($x)=>$x['team']===$oregonId));
    $oppPlays=array_values(array_filter($pbp,fn($x)=>$x['team']===$oppId));
    $score=auto_scores($orePlays,$oppPlays);
    if(!$score){log_line('WARN: insufficient classified plays for '. $slug.' ('.count($orePlays).'/'.count($oppPlays).')');continue;}
    $o=$score['offense'];$d=$score['opponent'];
    $sample=(($oregon['homeAway']??'')==='home'?'vs ':'at ').$name;
    $db['games'][$slug]=[
        'sample'=>$sample,'ops'=>$score['ops'],'dcs'=>$score['dcs'],
        'offensive_snaps'=>$o['total'],'defensive_snaps'=>$d['total'],
        'score_method'=>'DSC-PBP-v1: operational estimate; historical opponent Z normalization unavailable',
        'auto_generated'=>true,'source_event_id'=>$id,'source'=>'ESPN summary play-by-play',
        'offense'=>model_rows(['Run/pass calls'=>$o['run'].' runs / '.$o['pass'].' passes',
           'Third-and-4+ pass rate'=>round(100*$score['longPass'],1).'%',
           'After-pass repeat rate'=>round(100*$score['repeat'],1).'%',
           'Third down'=>$o['third_conversions'].'/'.$o['third_attempts'],
           'Final OPS'=>$score['ops']]),
        'defense'=>model_rows(['Defensive snaps'=>$d['total'],
           'Havoc events'=>$d['havoc'],
           'Sacks'=>$d['sacks'],'Interceptions'=>$d['interceptions'],
           'Third-down stops'=>($d['third_attempts']-$d['third_conversions']).'/'.$d['third_attempts'],
           'Final DCS'=>$score['dcs']]),
        'supplemental_offense'=>[],'supplemental_defense'=>[]
    ];
    $added++;$processed++;
    log_line('AUTO: '.$slug.' OPS '.$score['ops'].' DCS '.$score['dcs']);
}
log_line('FEED CHECK: '.$scheduled.' scheduled, '.$completed.' completed, '.$skipped.' already published, '.$attempted.' new completed games attempted, '.$unrecognized.' with unrecognized competitors.');
if(!$added){
    log_line('Nothing new. Existing Hub scores preserved.' . ($forceDryRun ? ' Dry-run mode.' : ''));
    if($completed===0 && $scheduled>0)log_line('NOTICE: ESPN returned scheduled games, but none marked completed. Check the API season/date before enabling automatic publishing.');
    exit(0);
}
$numO=$denO=$numD=$denD=0;
foreach($db['games'] as $game){
    if(!is_numeric($game['ops']??null)||!is_numeric($game['dcs']??null))continue;
    $on=(int)($game['offensive_snaps']??0);$dn=(int)($game['defensive_snaps']??0);
    if($on>0){$numO+=$game['ops']*$on;$denO+=$on;}
    if($dn>0){$numD+=$game['dcs']*$dn;$denD+=$dn;}
}
if($denO && $denD)$db['season']=['ops'=>round($numO/$denO,2),'dcs'=>round($numD/$denD,2),
    'method'=>'offensive-play weighted OPS / defensive-play weighted DCS','updated'=>gmdate('c')];
$encoded=json_encode($db,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
if($encoded===false){log_line('ERROR: JSON encoding failed');exit(4);}
if($forceDryRun){log_line('Dry run: no changes written');exit(0);}
$temp=tempnam($root,'dsc-stage-');
if($temp===false || file_put_contents($temp,$encoded."\n",LOCK_EX)===false){log_line('ERROR: cannot stage JSON');exit(5);}
chmod($temp,0644);
if(!rename($temp,$dataFile)){@unlink($temp);log_line('ERROR: could not replace JSON atomically');exit(6);}
log_line('SUCCESS: '.$added.' game(s) saved; live Hub connector reads updated JSON automatically');
