#!/usr/bin/env python3
"""Read-only GitHub collaboration inventory. Requires authenticated gh; no writes."""
import argparse
import datetime
import json
import os
from pathlib import Path
import re
import subprocess


def gh(args, payload=None):
    command = ['gh', 'api', *args]
    if payload is not None:
        command += ['--input', '-']
    result = subprocess.run(command, input=json.dumps(payload) if payload is not None else None,
                            capture_output=True, text=True, timeout=120)
    if result.returncode:
        raise RuntimeError(f'GitHub read failed ({result.returncode}); verify access/rate limits. '
                           + result.stderr[:300])
    return json.loads(result.stdout)


def pages(path):
    return [item for page in gh([path, '--paginate', '--slurp']) for item in page]


def graphql(query, variables):
    result = gh(['graphql'], {'query': query, 'variables': variables})
    if result.get('errors'):
        raise RuntimeError('GraphQL errors: '+json.dumps(result['errors']))
    return result['data']


def issue_relations(owner, name, number):
    fields = {
        'closedByPullRequestsReferences': 'nodes { number url merged }',
        'timelineItems': '''nodes { ... on ClosedEvent { createdAt actor { login }
            closer { __typename ... on PullRequest { number url merged }
            ... on Commit { oid url } } } }''',
    }
    result = {}
    for field, selection in fields.items():
        cursor = None
        nodes = []
        extra = ',itemTypes:[CLOSED_EVENT]' if field == 'timelineItems' else ''
        query = '''query($owner:String!,$name:String!,$number:Int!,$cursor:String){
          repository(owner:$owner,name:$name){issue(number:$number){CONNECTION(
          first:100,after:$cursor EXTRA){pageInfo{hasNextPage endCursor} SELECTION}}}}'''
        query = query.replace('CONNECTION', field).replace('EXTRA', extra).replace('SELECTION', selection)
        while True:
            data = graphql(query, {'owner': owner, 'name': name, 'number': number, 'cursor': cursor})
            issue = data['repository']['issue']
            if issue is None:
                raise RuntimeError(f'Issue disappeared during inventory: {number}')
            connection = issue[field]
            nodes += connection['nodes']
            if not connection['pageInfo']['hasNextPage']:
                break
            cursor = connection['pageInfo']['endCursor']
        result[field] = nodes
    return result


def pr_commits(owner, name, number):
    cursor = None
    commits = []
    total = None
    query = '''query($owner:String!,$name:String!,$number:Int!,$cursor:String){
      repository(owner:$owner,name:$name){pullRequest(number:$number){commits(first:100,after:$cursor){
        totalCount pageInfo{hasNextPage endCursor} nodes{commit{oid authoredDate committedDate}}
      }}}}'''
    while True:
        connection = graphql(query, {'owner': owner, 'name': name, 'number': number, 'cursor': cursor})['repository']['pullRequest']['commits']
        if total is not None and total != connection['totalCount']:
            raise RuntimeError('PR changed during pagination; refresh the inventory')
        total = connection['totalCount']
        commits += [n['commit'] for n in connection['nodes']]
        if not connection['pageInfo']['hasNextPage']:
            break
        cursor = connection['pageInfo']['endCursor']
    if len(commits) != total:
        raise RuntimeError('Incomplete PR commit inventory')
    if len({c['oid'] for c in commits}) != len(commits):
        raise RuntimeError('Duplicate PR commits across pages; refresh the inventory')
    return commits


def inventory(repo):
    owner, name = repo.split('/')
    info = gh([f'repos/{repo}'])
    items = pages(f'repos/{repo}/issues?state=all&per_page=100')
    pulls = pages(f'repos/{repo}/pulls?state=all&per_page=100')
    for kind, records in [('issues', items), ('pull requests', pulls)]:
        if len({i['number'] for i in records}) != len(records):
            raise RuntimeError(f'Duplicate {kind} across pages; source changed, refresh the inventory')
    if {i['number'] for i in items if 'pull_request' in i} != {p['number'] for p in pulls}:
        raise RuntimeError('Issue/PR inventories disagree; source changed or access is incomplete')
    issues = []
    for item in sorted(items, key=lambda i: i['number']):
        if 'pull_request' in item:
            continue
        entry = {k: item.get(k) for k in ['number', 'html_url', 'state', 'state_reason',
                                            'created_at', 'updated_at', 'closed_at', 'comments']}
        entry.update(issue_relations(owner, name, item['number']))
        issues.append(entry)
    prs = []
    for item in sorted(pulls, key=lambda i: i['number']):
        entry = {k: item.get(k) for k in ['number', 'html_url', 'state', 'created_at',
                                            'updated_at', 'closed_at', 'merged_at', 'merge_commit_sha']}
        entry.update({'base_sha': item['base']['sha'], 'head_sha': item['head']['sha'],
                      'commits': pr_commits(owner, name, item['number'])})
        prs.append(entry)
    return {'repository': info['full_name'], 'private': info['private'],
            'default_branch': info['default_branch'],
            'observed_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
            'issues': issues, 'pull_requests': prs,
            'counts': {'issues': len(issues), 'pull_requests': len(prs)},
            'scope': 'Numbers/states/dates, closing evidence and PR commit metadata. Full bodies, comments, reviews, attachments and Git objects are NOT exported.'}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--repo', required=True, help='Exact OWNER/REPO')
    parser.add_argument('--output', required=True, type=Path)
    args = parser.parse_args()
    if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repo):
        parser.error('--repo must be OWNER/REPO, not a URL')
    if args.output.exists() or args.output.is_symlink():
        parser.error('--output already exists; choose a fresh snapshot path')
    os.umask(0o077)
    result = inventory(args.repo)
    args.output.parent.mkdir(parents=True, exist_ok=True)
    # Exclusive creation preserves previous runs and prevents overwriting symlinks.
    with args.output.open('x') as handle:
        json.dump(result, handle, indent=2)
        handle.write('\n')
    print(json.dumps({'repository': result['repository'], 'counts': result['counts'],
                      'output': str(args.output)}))


if __name__ == '__main__':
    main()
