"""Fixed, bounded census of currently returned Steam-purchase reviews.

User authorized historical portfolio and authorship study. The bulk collector stays
paused; this separate scope has hard caps, fixed URLs, no retries and no media.
"""
from pathlib import Path
import json,time,datetime as dt,hashlib
import pandas as pd
import requests
R=Path(__file__).resolve().parent;ROOT=R.parent
NAMES=['a327ex','Rad Codex','Tuatara Games','Studio Fizbin','Desert Fox']
x=pd.read_parquet(ROOT/'portfolios-2026-09-06/games.parquet')
g=pd.read_parquet(ROOT/'analysis-2026-09-05/games.parquet')
target=x[x.credit.str.strip().str.casefold().isin([n.casefold() for n in NAMES])].copy()
eliza=g[g.appid.eq(716500)].copy();eliza['credit']='Zachtronics'
free_context=g[g.appid.isin([769970,1673600,1746370])].copy();free_context['credit']='Desert Fox'
target=pd.concat([target,eliza,free_context],ignore_index=True)
cols=['appid','name','credit','first_date','reviews','positive_pct','short_description','description','store_url','known_ea_history','is_free']
target=target[cols].sort_values(['credit','first_date'])
assert len(target)==27 and target.appid.nunique()==27
target.to_parquet(R/'targets.parquet',index=False)
spec={'selection':'Five deliberately selected observed catalogs plus Eliza; not a representative sample. Original 24-game paid sample expanded by three currently free Desert Fox games after identity/history audit, within the original caps. Current free status does not establish historical price.',
 'targets':target[['appid','name','credit','reviews']].to_dict('records'),
 'max_requests':250,'max_reviews':25000,'max_response_bytes':2000000,'min_interval_seconds':2,'retries':0,
 'purchase_type':'steam','language':'all','review_type':'all','filter_offtopic_activity':1,
 'data':'Surviving currently returned reviews and their creation dates; not historical votes, deleted reviews or release-time counts of every review that ever existed.',
 'privacy':'No account IDs, names, profile links or avatars retained. No reviewer matching across games.',
 'bulk_policy_sha256':hashlib.sha256((ROOT/'network_policy.json').read_bytes()).hexdigest(),'active':True}
policy=R/'collection_policy.json';policy.write_text(json.dumps(spec,indent=2,ensure_ascii=False))
pages=R/'review_pages';pages.mkdir(exist_ok=True)
request_log=R/'requests.jsonl'
old_lines=request_log.read_text().splitlines() if request_log.exists() else []
requests_used=len(old_lines);allrows=[];statuses=[];last_request=0.0
try:
 for app in target.itertuples():
  cursor='*';seen=set();rows=[];page=0;summary=None;complete=False
  while True:
   dest=pages/f'{app.appid}_{page:03}.json'
   if dest.exists():
    data=json.loads(dest.read_text());assert data['requested_cursor']==cursor
   else:
    if requests_used>=spec['max_requests']:raise RuntimeError('Request cap reached')
    if len(allrows)+len(rows)>=spec['max_reviews']:raise RuntimeError('Review cap reached')
    delay=max(0,2-(time.monotonic()-last_request))
    if delay:time.sleep(delay)
    params={'json':1,'filter':'recent','language':'all','purchase_type':'steam','review_type':'all','num_per_page':100,'filter_offtopic_activity':1,'cursor':cursor}
    url=f'https://store.steampowered.com/appreviews/{app.appid}'
    last_request=time.monotonic();started=dt.datetime.now(dt.timezone.utc).isoformat()
    # Log before requesting, so failed requests also consume the cap.
    with request_log.open('a') as f:f.write(json.dumps({'at':started,'appid':app.appid,'page':page,'params':params})+'\n')
    requests_used+=1
    response=requests.get(url,params=params,timeout=(10,30),headers={'User-Agent':'SteamCatalogResearch/0.2 (bounded public review study)'})
    response.raise_for_status()
    assert len(response.content)<=spec['max_response_bytes']
    raw=response.json();assert raw.get('success')==1
    safe=[]
    for r in raw.get('reviews',[]):
     safe.append({k:r.get(k) for k in ['recommendationid','language','review','voted_up','timestamp_created','timestamp_updated','steam_purchase','received_for_free','written_during_early_access']}|
                 {'playtime_at_review_minutes':r.get('author',{}).get('playtime_at_review')})
    data={'at':started,'url':response.url,'requested_cursor':cursor,'next_cursor':raw.get('cursor'),'query_summary':raw.get('query_summary'),'reviews':safe}
    dest.write_text(json.dumps(data,ensure_ascii=False))
   if page==0:summary=data['query_summary']
   batch=data['reviews'];new=[r for r in batch if r['recommendationid'] not in seen]
   for r in new:
    seen.add(r['recommendationid']);rows.append(dict(appid=app.appid,game=app.name,developer=app.credit,**r))
   assert all(r['steam_purchase'] for r in rows)
   if not batch:complete=True;break
   nxt=data.get('next_cursor')
   if not nxt or nxt==cursor:raise RuntimeError(f'Nonadvancing cursor for {app.appid}')
   if batch and not new:raise RuntimeError(f'Duplicate-only page for {app.appid}')
   cursor=nxt;page+=1
  allrows.extend(rows)
  statuses.append({'appid':app.appid,'game':app.name,'complete':complete,'pages':page+1,'collected':len(rows),'summary_total':summary.get('total_reviews') if summary else None,
                   'old_snapshot_total':app.reviews,'earliest_created':min([r['timestamp_created'] for r in rows],default=None)})
  pd.DataFrame(allrows).to_parquet(R/'reviews.parquet',index=False)
  (R/'collection_status.json').write_text(json.dumps(statuses,indent=2))
  print(app.name,'complete',len(rows),'summary',summary.get('total_reviews') if summary else None,'requests',requests_used,flush=True)
finally:
 spec['active']=False;spec['requests_used']=requests_used;spec['reviews_written']=len(allrows)
 policy.write_text(json.dumps(spec,indent=2,ensure_ascii=False))
 assert hashlib.sha256((ROOT/'network_policy.json').read_bytes()).hexdigest()==spec['bulk_policy_sha256']
print('Collection closed:',requests_used,'requests,',len(allrows),'reviews.',flush=True)
