#!/usr/bin/env python3
# encoding=UTF-8

# Copyright © 2013-2022 Jakub Wilk <jwilk@jwilk.net>
# SPDX-License-Identifier: MIT

import argparse
import builtins
import concurrent.futures
import fcntl
import functools
import json
import operator
import os
import re
import socket
import sys
import urllib.request

int(0_0)  # Python >= 3.6 is required

prog = os.path.basename(sys.argv[0])

socket.getaddrinfo = functools.lru_cache(maxsize=None)(socket.getaddrinfo)

# Let's trick http.client into writing debugging stuff to stderr, not stdout.
print = functools.partial(builtins.print, file=sys.stdout)  # pylint: disable=redefined-builtin
sys.stdout = sys.stderr

def create_cache_dir():
    path = os.getenv('XDG_CACHE_HOME', '')
    if not path.startswith('/'):
        path = os.path.join(os.path.expanduser('~'), '.cache')
    path = os.path.join(path, 'github-vanity')
    os.makedirs(path, 0o700, exist_ok=True)
    return path

def format_user_name(user):
    template = '<{login}>'
    if user.get('name') is not None:
        template = '{name} ' + template
    return template.format_map(user)

class Dumper():

    headers = {
        'User-Agent': 'github-vanity (https://github.com/jwilk/github-vanity)'
    }
    max_workers = 8
    debug_level = 0

    def __init__(self):
        self.cache_dir = create_cache_dir()
        self.cache_path = os.path.join(self.cache_dir, 'cache.json')
        self.cache = None
        self.lock_fd = None
        try:
            token = os.environ['GITHUB_VANITY_TOKEN']
        except KeyError:
            self.authorization = None
        else:
            self.authorization = 'token ' + token

    def __enter__(self):
        if self.lock_fd is not None:
            raise RuntimeError(f'{self!r} is already locked')
        self.lock_fd = os.open(self.cache_dir, os.O_RDONLY)
        try:
            fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            print(f'{prog}: waiting for the lock...', end='', file=sys.stderr)
            sys.stderr.flush()
            fcntl.flock(self.lock_fd, fcntl.LOCK_EX)
            print('', file=sys.stderr)
        try:
            with open(self.cache_path, 'rt', encoding='UTF-8') as fp:
                self.cache = json.load(fp)
        except FileNotFoundError:
            self.cache = {}
        return self

    def __exit__(self, *exc_info):
        if self.lock_fd is None:
            return
        try:
            with open(self.cache_path + '.tmp', 'wt', encoding='UTF-8') as fp:
                json.dump(self.cache, fp)
            os.rename(self.cache_path + '.tmp', self.cache_path)
            self.cache = None
        finally:
            os.close(self.lock_fd)
            os.lock_fd = None

    def _parse_link_header(self, link_header):
        if link_header is None:
            return {}
        items = re.split(r',\s+(?=<)', link_header)
        result = {}
        for item in items:
            match = re.match(r'\A<([^>]+)>; rel="(\w+)"\Z', item)
            (url, rel) = match.groups()
            result[rel] = dict(url=url, rel=rel)
        return result

    def _read_json(self, fp):
        with fp:
            data = fp.read()
            data = data.decode('UTF-8')
            return json.loads(data)

    def _build_opener(self):
        # TODO: Get rid of this function once
        # <https://github.com/python/cpython/issues/99352> is fixed.
        handlers = [
            Handler(debuglevel=self.debug_level)
            for Handler in [urllib.request.HTTPHandler, urllib.request.HTTPSHandler]
        ]
        return urllib.request.build_opener(*handlers)

    def _get(self, url, needs=()):
        headers = dict(self.headers)
        if self.authorization is not None:
            headers['Authorization'] = self.authorization
        try:
            cached = self.cache[url]
            for need in needs:
                cached[need]  # pylint: disable=pointless-statement
        except KeyError:
            pass
        else:
            headers['If-None-Match'] = cached['etag']
        request = urllib.request.Request(url, headers=headers)
        opener = self._build_opener()
        try:
            response = opener.open(request)  # pylint: disable=bad-option-value,consider-using-with
        except urllib.error.HTTPError as exc:
            if exc.code == 304:
                return cached
            if exc.code >= 400:
                data = self._read_json(exc)
                exc.msg = data['message']
            raise
        data = self._read_json(response)
        links = self._parse_link_header(response.headers['Link'])
        result = dict(
            data=data,
            links=links,
        )
        etag = response.headers['ETag']
        if etag is not None:
            result['etag'] = etag
            self.cache[url] = result
        return result

    def get(self, url):
        return self._get(url)['data']

    def async_get(self, urls):
        with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            yield from executor.map(self.get, urls)

    def get_list(self, url, per_page=100):
        url = f'{url}?per_page={per_page}'
        data = []
        while True:
            result = self._get(url, needs={'links'})
            data += result['data']
            try:
                url = result['links']['next']['url']
            except KeyError:
                break
        return data

    def async_get_lists(self, urls):
        with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            yield from executor.map(self.get_list, urls)

    def get_user(self, login):
        url = f'https://api.github.com/users/{login}'
        return self.get(url)

    def async_get_users(self, logins):
        with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            yield from executor.map(self.get_user, logins)

    def dump_followers(self, login, ignored=()):
        print(f'{login}:')
        url = f'https://api.github.com/users/{login}/followers'
        followers = self.get_list(url)
        users = self.async_get_users(
            follower['login']
            for follower in followers
            if follower['login'] not in ignored
        )
        users = sorted(users, key=operator.itemgetter('login'))
        users = [
            format_user_name(user)
            for user in users
        ]
        self.cache.setdefault('#user', {})
        self.cache['#user'][login] = users
        for user in users:
            print('-', user)
        print()

    def dump_repositories(self, login, ignored=()):
        url = f'https://api.github.com/users/{login}/repos'
        repos = self.get_list(url)
        repos = sorted(repos, key=operator.itemgetter('name'))
        repos_stargazers = self.async_get_lists(
            repo['stargazers_url'] for repo in repos
        )
        repos_subscribers = self.async_get_lists(
            repo['subscribers_url'] for repo in repos
        )
        for repo, stargazers, subscribers in zip(repos, repos_stargazers, repos_subscribers):
            def userset(users):
                return {
                    u['login']
                    for u in users
                    if u['login'] != login
                    and u['login'] not in ignored
                }
            users = self.async_get_users(
                userset(stargazers) | userset(subscribers)
            )
            users = sorted(users, key=operator.itemgetter('login'))
            users = [
                format_user_name(user)
                for user in users
            ]
            repo_name = repo['name']
            self.cache.setdefault('#repo', {})
            self.cache['#repo'].setdefault(login, {})
            self.cache['#repo'][login][repo_name] = users
            if not users:
                continue
            print(f'{login}/{repo_name}:')
            for user in users:
                print('-', user)
            print()

    def dump(self, login, ignored=()):
        self.dump_followers(login, ignored=ignored)
        self.dump_repositories(login, ignored=ignored)

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--ignore', metavar='USER', action='append', help='ignore this user')
    gr = ap.add_argument_group('advanced options')
    gr.add_argument('--max-connections', metavar='N', default=Dumper.max_workers,
        help=f'max number of parallel connections (default: {Dumper.max_workers})', type=int
    )
    gr.add_argument('--debug', action='store_true', help='enable HTTP debugging')
    ap.add_argument('user', metavar='USER', nargs='+', help='user (or organization) to look after')
    options = ap.parse_args()
    if options.debug:
        Dumper.debug_level = 1
    Dumper.max_workers = options.max_connections
    ignored = set(options.ignore or [])
    with Dumper() as dumper:
        for user in options.user:
            dumper.dump(user, ignored=ignored)

if __name__ == '__main__':
    main()

# vim:ts=4 sts=4 sw=4 et
