#!/usr/bin/env python3

# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

import argparse
import collections
import datetime
import json
import logging
import math
import operator

import prettytable
import requests

from openstack_governance import governance
from openstack_governance import members


LOG = logging.getLogger(__name__)

TC_SIZE = 9

# For formal-vote votes, age in days that a change has to have existed
# before it can be approved
creation_age_threshold = datetime.timedelta(days=7)


def decode_json(raw):
    """Trap JSON decoding failures and provide more detailed errors

    Remove ')]}' XSS prefix from data if it is present, then decode it
    as JSON and return the results.

    :param raw: Response text from API
    :type raw: str

    """

    # Gerrit's REST API prepends a JSON-breaker to avoid XSS vulnerabilities
    if raw.text.startswith(")]}'"):
        trimmed = raw.text[4:]
    else:
        trimmed = raw.text

    # Try to decode and bail with much detail if it fails
    try:
        decoded = json.loads(trimmed)
    except Exception:
        LOG.error(
            '\nrequest returned %s error to query:\n\n    %s\n'
            '\nwith detail:\n\n    %s\n',
            raw, raw.url, trimmed)
        raise
    return decoded


def query_gerrit(offset=0):
    """Query the Gerrit REST API"""
    url = 'https://review.opendev.org/changes/'
    LOG.debug('fetching %s', url)
    raw = requests.get(
        url,
        params={
            'n': '100',
            'start': offset,
            'q': 'project:openstack/governance is:open',
            'o': [
                'ALL_REVISIONS',
                'REVIEWER_UPDATES',
                'DETAILED_ACCOUNTS',
                'CURRENT_COMMIT',
                'LABELS',
                'DETAILED_LABELS',
                'MESSAGES',
            ],
        },
        headers={'Accept': 'application/json'},
    )
    return decode_json(raw)


def to_datetime(s, default=None):
    "Convert a string to a datetime.datetime instance"
    # Ignore the trailing decimal seconds.
    if s is None:
        return default
    s = s.rpartition('.')[0]
    return datetime.datetime.strptime(s, '%Y-%m-%d %H:%M:%S')


def find_latest_revision(review):
    result = None
    result_date = None
    for rev in review.get('revisions', {}).values():
        rev_date = to_datetime(rev.get('created'))
        if not result or rev_date > result_date:
            result = rev
            result_date = rev_date
    return result


def count_votes(review, group='Rollcall-Vote'):
    votes = collections.Counter()
    votes.update(
        vote.get('value')
        for vote in review['labels'].get(group, {}).get('all', [])
    )
    if None in votes:
        del votes[None]
    return votes


def when_majority(review, required_count):
    "Return the date when the vote reached the required count."
    n = 0
    votes = review['labels'].get('Rollcall-Vote', {}).get('all', [])
    now = datetime.datetime.now()
    for vote in sorted(votes, key=lambda x: to_datetime(x.get('date'), now)):
        if vote.get('value'):
            n += 1
        if n >= required_count:
            d = vote.get('date')
            if d:
                return to_datetime(d)


def format_votes(votes):
    return 'nay:{:2d} / abs:{:2d} / yes:{:2d}'.format(
        votes.get(-1, 0), votes.get(0, 0), votes.get(1, 0)
    )


def get_votes_by_person(member, review):
    for label in ['Code-Review', 'Rollcall-Vote']:
        for vote in review['labels'].get(label, {}).get('all', []):
            if member['gerritid'] == vote['_account_id']:
                yield vote


def has_approved(member, review):
    return any(
        vote.get('value', 0) == 1
        for vote in get_votes_by_person(member, review)
    )


def has_rejected(member, review):
    return any(
        vote.get('value', 0) == -1
        for vote in get_votes_by_person(member, review)
    )


def has_commented(member, review):
    desired_revision = max(
        r.get('_number', -1)
        for r in review.get('revisions', {}).values()
    )
    for msg in review.get('messages', []):
        if msg.get('_revision_number', -1) != desired_revision:
            continue
        if msg.get('author', {}).get('_account_id', '') == member['gerritid']:
            return True


def all_changes():
    offset = 0
    while True:
        changes = query_gerrit(offset)

        yield from changes

        if changes and changes[-1].get('_more_changes', False):
            offset += 100
        else:
            break


def get_one_status(change, delegates, tc_members):
    topic = change.get('topic', 'unknown topic')
    subject = change.get('subject')
    owner = change.get('owner', {}).get('name')
    url = 'https://review.opendev.org/{}\n'.format(change['_number'])

    latest = find_latest_revision(change)
    latest_created = to_datetime(latest['created'])
    now = datetime.datetime.utcnow()
    age = now - latest_created
    earliest = ''

    code_reviews = count_votes(change, 'Code-Review')
    votes = count_votes(change)
    workflow = count_votes(change, 'Workflow')
    verified = count_votes(change, 'Verified')

    can_approve = 'no'

    if workflow[-1]:
        can_approve = 'WIP'

    elif workflow[1]:
        can_approve = 'APPROVED'

    elif verified[-1]:
        can_approve = 'NO, verification failure'
        earliest = 'when passing'

    elif topic == 'on-hold':
        can_approve = 'on hold'

    elif topic == 'formal-vote':
        # https://governance.openstack.org/tc/reference/charter.html#motions
        parts = []

        # At least at third rounded up positive votes and more positive than
        # negative votes.
        necessary_votes = math.ceil(TC_SIZE / 3)
        votes_to_approve = votes[1] >= necessary_votes and votes[1] > votes[-1]
        reached_necessary_votes = when_majority(change, necessary_votes)

        parts.append('last change on {}'.format(
            latest_created.isoformat(timespec='minutes')
        ))

        # At least creation_age_threshold days old.
        if age < creation_age_threshold:
            time_to_approve = False
            parts.append("has not been open %d days" %
                         creation_age_threshold.days)
            earliest = str(latest_created + creation_age_threshold)
        elif reached_necessary_votes:
            time_to_approve = True
        else:
            time_to_approve = False
            earliest = "after {} positive votes".format(necessary_votes)

        if votes_to_approve and time_to_approve:
            parts.append('YES')

        if reached_necessary_votes and not time_to_approve:
            parts.append('enough required votes but too soon')

        # Even if we can approve it, if there are dissenting votes we
        # may want to continue discussion or refine the proposal.
        if votes[-1]:
            if votes[-1] >= votes[1]:
                parts.append('too many dissenting votes')
            else:
                parts.append('dissenting votes')

        if votes[1] < necessary_votes:
            parts.append('not enough votes')
        elif votes[1] == necessary_votes:
            parts.append('minimum favorable votes')
        else:
            parts.append('sufficient votes')

        if not (votes[1] > math.floor(TC_SIZE / 2)):
            # Even if we can approve it, if the majority have not
            # voted yes we may want to continue discussion or call for
            # a final vote.
            parts.append('plz whip votes - no majority and things can change')

        can_approve = ',\n'.join(parts)

    elif topic == 'charter-change':
        # https://governance.openstack.org/tc/reference/charter.html#amendment
        parts = []

        # At least 2/3 positive votes.
        necessary_votes = math.ceil(TC_SIZE / 3 * 2)
        votes_to_approve = votes[1] > necessary_votes

        # Wait least 3 days after reaching majority.
        reached_supermajority = when_majority(change, necessary_votes)
        if reached_supermajority:
            earliest = str(
                reached_supermajority.date() + datetime.timedelta(4)
            )
            since_supermajority = now.date() - reached_supermajority.date()
            time_to_approve = since_supermajority > datetime.timedelta(3)
        else:
            time_to_approve = False
            earliest = '4 days after {} positive votes'.format(necessary_votes)

        if votes_to_approve and time_to_approve:
            parts.append('CAN APPROVE')

        if reached_supermajority and not time_to_approve:
            parts.append('enough required votes but too soon')

        # Even if we can approve it, if there are dissenting votes we
        # may want to continue discussion or refine the proposal.
        if votes[-1]:
            parts.append('dissenting votes')

        if votes[1] < necessary_votes:
            parts.append('not enough votes')
        elif votes[1] == necessary_votes:
            parts.append('minimum favorable votes')
        else:
            parts.append('enough votes')

        can_approve = ',\n'.join(parts)

    elif topic in (
        'goal-proposal',
        'code-change',
        'documentation-change',
        'election-results',
        'typo-fix',
    ):
        # https://governance.openstack.org/tc/reference/house-rules.html#community-wide-goal-proposals
        # https://governance.openstack.org/tc/reference/house-rules.html#code-changes
        # https://governance.openstack.org/tc/reference/house-rules.html#documentation-changes
        # https://governance.openstack.org/tc/reference/house-rules.html#election-results
        # https://governance.openstack.org/tc/reference/house-rules.html#typo-fixes

        earliest = 'Can be voted anytime'

        if votes[-1] or code_reviews[-1]:
            can_approve = 'dissenting votes'
        elif votes[1] < 2:
            can_approve = 'not enough reviews'
        else:
            can_approve = 'CAN APPROVE'

    elif topic in delegates.keys():
        # https://governance.openstack.org/tc/reference/house-rules.html#delegated-metadata
        approver_name = delegates[topic]
        can_approve = 'delegated to {}'.format(approver_name)
        if has_approved(approver_name, change):
            can_approve += '\nYES'
        elif has_rejected(approver_name, change):
            can_approve += '\nNO - delegate voted against'
        elif has_commented(approver_name, change):
            can_approve += '\ndelegate has commented'

    elif topic in ('project-update', 'new-project'):
        # https://governance.openstack.org/tc/reference/house-rules.html#other-project-team-updates

        if votes[-1] or code_reviews[-1]:
            can_approve = 'dissenting votes'
        elif votes[1] < 2:
            can_approve = 'not enough reviews'
        else:
            can_approve = 'CAN APPROVE'

    elif topic == 'goal-update':
        # https://governance.openstack.org/tc/reference/house-rules.html#goal-updates-from-ptls

        # At least 7 days old.
        earliest = str(latest_created + creation_age_threshold)

        if votes[-1]:
            can_approve = 'dissenting votes'
        elif age <= datetime.timedelta(7):
            can_approve = 'too soon'
        else:
            can_approve = 'CAN APPROVE'

    else:
        topic = 'unknown topic'
        can_approve = 'unknown topic'

    votes = '\n'.join([
        'CR:' + format_votes(code_reviews),
        ' V:' + format_votes(votes),
    ])

    tc_member_votes = {}
    for member in tc_members:
        name = member['name']
        if has_approved(member, change):
            tc_member_votes[name] = '+'
        elif has_rejected(member, change):
            tc_member_votes[name] = '-'
        elif has_commented(member, change):
            tc_member_votes[name] = 'C'
        else:
            tc_member_votes[name] = ' '
    member_votes = '\n'.join(
        '{} : {}'.format(value, name)
        for name, value in sorted(tc_member_votes.items())
    )

    return {
        'Topic': topic,
        'Subject': subject,
        'Summary': '\n'.join([
            subject.strip(),
            '',
            url,
            'Submitted by: {}'.format(owner.strip())
        ]),
        'Owner': owner,
        'URL': url,
        'Age': age.days,
        'Date': latest_created.date(),
        'Can Approve': can_approve,
        'Status': '\n'.join([topic, can_approve,
                             '{} days old'.format(age.days),
                             'earliest: {}'.format(earliest)]),
        'Earliest': earliest,
        'Votes': votes,
        'Members': member_votes,
    }


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        '--verbose', '-v',
        action='store_const',
        dest='log_level',
        default=logging.INFO,
        const=logging.DEBUG,
    )

    args = parser.parse_args()
    logging.basicConfig(
        level=args.log_level,
    )

    tc_members = members.parse_members_file('./reference/members.yaml')
    # NOTE(mnaser): In order to consistently and properly track the votes,
    #               we need to lookup every users Gerrit account ID based
    #               on their email.
    for member in tc_members:
        raw = requests.get(
            'https://review.opendev.org/accounts/%s' % member['email'],
            headers={'Accept': 'application/json'},
        )
        member['gerritid'] = decode_json(raw).get('_account_id')

    gov = governance.Governance.from_local_repo()
    release_team = gov.get_team('Release Management')

    delegates = {
        'release-management': release_team.ptl['name'],
    }
    for tag, name in sorted(delegates.items()):
        print('Delegating {} tags to {}'.format(tag, name))

    status = sorted(
        (get_one_status(change, delegates, tc_members)
         for change in all_changes()),
        key=operator.itemgetter('URL'),
    )

    columns = (
        'Summary',
        'Status',
        'Votes',
        'Members',
    )

    x = prettytable.PrettyTable(
        field_names=columns,
        hrules=prettytable.ALL,
    )
    x.align['Summary'] = 'l'
    x.align['Status'] = 'l'
    x.align['Votes'] = 'l'
    x.align['Members'] = 'l'
    for row in status:
        x.add_row([row[c] for c in columns])
    print(x.get_string())


if __name__ == '__main__':
    main()
