#!/usr/bin/env python3
"""Read-only GitHub metadata collector. Never upload credentials or records to the directory."""
import argparse
import json
import os
import re
import time
from datetime import datetime, timezone, date
from pathlib import Path
from urllib.request import Request, build_opener, HTTPRedirectHandler
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, quote

class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        return None


def fetch(path, token=''):
    headers={'Accept':'application/vnd.github+json','User-Agent':'compliance-evidence-collector','X-GitHub-Api-Version':'2022-11-28'}
    if token: headers['Authorization']='Bearer '+token
    try:
        with build_opener(NoRedirect).open(Request('https://api.github.com'+path,headers=headers),timeout=20) as response:
            raw=response.read(4_000_001)
            if len(raw)>4_000_000: raise ValueError('Response exceeds 4 MB')
            return json.loads(raw)
    except HTTPError as exc:
        # Response bodies and headers can contain sensitive details. Never copy them into evidence.
        raise ValueError(f'GitHub request unavailable (HTTP {exc.code}); permissions, rate limits, or resource visibility may apply.') from None
    except (URLError,TimeoutError):
        raise ValueError('GitHub request unavailable due to a network timeout or connection error.') from None


def collect(repository, start, end, token='', private=False, get=fetch, now=None):
    if not re.fullmatch(r'[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}',repository): raise ValueError('Use owner/repository, not a URL')
    if date.fromisoformat(start)>date.fromisoformat(end): raise ValueError('Start must precede end')
    base='/repos/'+repository
    repo=get(base,token)
    if repo.get('private') and not private: raise ValueError('Private collection requires --allow-private and an authorized token')
    collected=(now or datetime.now(timezone.utc)).isoformat()
    records=[{'source':'https://api.github.com'+base,'kind':'repository snapshot','collected_at':collected,'covered_period':'Point-in-time settings; not historical evidence','values':{k:repo.get(k) for k in ['full_name','private','default_branch','archived','disabled']}}]
    errors=[]
    try:
        branch=get(base+'/branches/'+quote(repo['default_branch'],safe=''),token)
        records.append({'source':branch.get('_links',{}).get('html') or repo.get('html_url'),'kind':'default branch snapshot','collected_at':collected,'covered_period':'Point-in-time settings; not historical evidence','values':{'name':branch.get('name'),'protected':branch.get('protected'),'commit':branch.get('commit',{}).get('sha')}})
    except (ValueError,KeyError) as exc: errors.append('Branch metadata unavailable: '+str(exc))
    runs=[];complete=False
    try:
        for page in range(1,11):
            payload=get(base+'/actions/runs?'+urlencode({'created':start+'..'+end,'per_page':100,'page':page}),token)
            batch=payload['workflow_runs']
            if not isinstance(batch,list): raise ValueError('Unexpected workflow response')
            runs.extend({k:run.get(k) for k in ['id','html_url','head_sha','status','conclusion','created_at','updated_at']} for run in batch)
            if len(batch)<100: complete=True;break
        if not complete: errors.append('Workflow sample capped at 1,000 runs; coverage is incomplete')
    except (ValueError,KeyError) as exc: errors.append('Workflow collection incomplete: '+str(exc))
    records.append({'source':'https://api.github.com'+base+'/actions/runs','kind':'workflow run metadata','collected_at':collected,'covered_period':start+' through '+end,'complete':complete,'values':runs})
    packet={'version':1,'repository':repository,'collected_at':collected,'requested_period':{'start':start,'end':end},'records':records,'limitations':errors+['Metadata only; no logs, artifacts, approvals, or proof of control effectiveness. Empty results do not establish no CI activity outside GitHub Actions. Settings snapshots do not establish settings throughout the requested period.']}
    worksheet={'version':1,'tool':'evidence-planner','project':('GitHub evidence: '+repository)[:200],'records':[{'id':'r-github-review','values':{'request':'Review collected GitHub metadata against the agreed evidence request.','scope':repository+'; workflow period '+start+' through '+end+'; settings are collection-time snapshots.','owner':'','source':'packet.json; GitHub API repository, default branch, and workflow-run metadata.','cadence':'','due':'','status':'Planned','reviewer':'','notes':'Confirm scope, coverage, owner, and reviewer. '+ ' '.join(packet['limitations'])}}]}
    return packet,worksheet


def main():
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('repository');parser.add_argument('--start',required=True);parser.add_argument('--end',required=True);parser.add_argument('--output',type=Path,required=True)
    parser.add_argument('--allow-private',action='store_true');parser.add_argument('--use-token',action='store_true',help='Read GITHUB_TOKEN from environment; sent only to api.github.com')
    parser.add_argument('--repeat-seconds',type=int,default=0,help='Optional local refresh loop; minimum 3600 seconds. Stop with Ctrl-C.')
    args=parser.parse_args()
    if args.repeat_seconds and args.repeat_seconds<3600: parser.error('Repeat interval must be at least 3600 seconds')
    token=os.environ.get('GITHUB_TOKEN','') if args.use_token else ''
    while True:
        packet,worksheet=collect(args.repository,args.start,args.end,token,args.allow_private)
        folder=args.output/datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ');folder.mkdir(mode=0o700,parents=True,exist_ok=False)
        for name,data in [('packet.json',packet),('worksheet.json',worksheet)]:
            target=folder/name
            with os.fdopen(os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600),'w') as handle:
                handle.write(json.dumps(data,indent=2)+'\n')
        print('Wrote packet and planned worksheet to '+str(folder))
        if not args.repeat_seconds: break
        time.sleep(args.repeat_seconds)

if __name__=='__main__':
    try: main()
    except (ValueError,KeyError) as exc: raise SystemExit(str(exc))
