#!/usr/bin/python

# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import logging

import common

import httplib
import httplib2
from autotest_lib.server.cros.dynamic_suite import constants
from chromite.lib import gdata_lib

try:
  from apiclient.discovery import build as apiclient_build
  from apiclient import errors as apiclient_errors
  from oauth2client import file as oauth_client_fileio
except ImportError as e:
  apiclient_build = None
  logging.debug("API client for bug filing disabled. %s", e)


class ProjectHostingApiException(Exception):
    """
    Raised when an api call fails, since the actual
    HTTP error can be cryptic.
    """


class BaseIssue(gdata_lib.Issue):
    """Base issue class with the minimum data to describe a tracker bug.
    """
    def __init__(self, t_issue):
        kwargs = {}
        kwargs.update((keys, t_issue.get(keys))
                       for keys in gdata_lib.Issue.SlotDefaults.keys())
        super(BaseIssue, self).__init__(**kwargs)


class Issue(BaseIssue):
    """
    Class representing an Issue and it's related metadata.
    """
    def __init__(self, t_issue):
        """
        Initialize |self| from tracker issue |t_issue|

        @param t_issue: The base issue we want to use to populate
                        the member variables of this object.

        @raises ProjectHostingApiException: If the tracker issue doesn't
            contain all expected fields needed to create a complete issue.
        """
        super(Issue, self).__init__(t_issue)

        try:
            # The value keyed under 'summary' in the tracker issue
            # is, unfortunately, not the summary but the title. The
            # actual summary is the update at index 0.
            self.summary = t_issue.get('updates')[0]
            self.comments = t_issue.get('updates')[1:]

            # open or closed statuses are classified according to labels like
            # unconfirmed, verified, fixed etc just like through the front end.
            self.state = t_issue.get(constants.ISSUE_STATE)
            self.merged_into = None
            if (t_issue.get(constants.ISSUE_STATUS)
                    == constants.ISSUE_DUPLICATE and
                constants.ISSUE_MERGEDINTO in t_issue):
                parent_issue_dict = t_issue.get(constants.ISSUE_MERGEDINTO)
                self.merged_into = parent_issue_dict.get('issueId')
        except KeyError as e:
            raise ProjectHostingApiException('Cannot create a '
                    'complete issue %s, tracker issue: %s' % (e, t_issue))


class ProjectHostingApiClient():
    """
    Client class for interaction with the project hosting api.
    """

    # Maximum number of results we would like when querying the tracker.
    _max_results_for_issue = 50
    _start_index = 1


    def __init__(self, oauth_credentials, project_name,
                 monorail_server='staging'):
        if apiclient_build is None:
            raise ProjectHostingApiException('Cannot get apiclient library.')

        if not oauth_credentials:
            raise ProjectHostingApiException('No oauth_credentials is provided.')

        # TODO(akeshet): This try-except is due to incompatibility of phapi_lib
        # with oauth2client > 2. Until this is fixed, this is expected to fail
        # and bug filing will be effectively disabled. crbug.com/648489
        try:
          storage = oauth_client_fileio.Storage(oauth_credentials)
          credentials = storage.get()
        except Exception as e:
          raise ProjectHostingApiException('Incompaible credentials format, '
                                           'or other exception. Will not file '
                                           'bugs.')
        if credentials is None or credentials.invalid:
            raise ProjectHostingApiException('Invalid credentials for Project '
                                             'Hosting api. Cannot file bugs.')

        http = credentials.authorize(httplib2.Http())
        try:
            url = ('https://monorail-%s.appspot.com/_ah/api/discovery/v1/'
                   'apis/{api}/{apiVersion}/rest' % monorail_server)
            self._codesite_service = apiclient_build(
                "monorail", "v1", http=http,
                discoveryServiceUrl=url)
        except (apiclient_errors.Error, httplib2.HttpLib2Error,
                httplib.BadStatusLine) as e:
            raise ProjectHostingApiException(str(e))
        self._project_name = project_name


    def _execute_request(self, request):
        """
        Executes an api request.

        @param request: An apiclient.http.HttpRequest object representing the
                        request to be executed.
        @raises: ProjectHostingApiException if we fail to execute the request.
                 This could happen if we receive an http response that is not a
                 2xx, or if the http object itself encounters an error.

        @return: A deserialized object model of the response body returned for
                 the request.
        """
        try:
            return request.execute()
        except (apiclient_errors.Error, httplib2.HttpLib2Error,
                httplib.BadStatusLine) as e:
            msg = 'Unable to execute your request: %s'
            raise ProjectHostingApiException(msg % e)


    def _get_field(self, field):
        """
        Gets a field from the project.

        This method directly queries the project hosting API using bugdroids1's,
        api key.

        @param field: A selector, which corresponds loosely to a field in the
                      new bug description of the crosbug frontend.
        @raises: ProjectHostingApiException, if the request execution fails.

        @return: A json formatted python dict of the specified field's options,
                 or None if we can't find the api library. This dictionary
                 represents the javascript literal used by the front end tracker
                 and can hold multiple filds.

                The returned dictionary follows a template, but it's structure
                is only loosely defined as it needs to match whatever the front
                end describes via javascript.
                For a new issue interface which looks like:

                field 1: text box
                              drop down: predefined value 1 = description
                                         predefined value 2 = description
                field 2: text box
                              similar structure as field 1

                you will get a dictionary like:
                {
                    'field name 1': {
                        'project realted config': 'config value'
                        'property': [
                            {predefined value for property 1, description},
                            {predefined value for property 2, description}
                        ]
                    },

                    'field name 2': {
                        similar structure
                    }
                    ...
                }
        """
        project = self._codesite_service.projects()
        request = project.get(projectId=self._project_name,
                              fields=field)
        return self._execute_request(request)


    def _list_updates(self, issue_id):
        """
        Retrieve all updates for a given issue including comments, changes to
        it's labels, status etc. The first element in the dictionary returned
        by this method, is by default, the 0th update on the bug; which is the
        entry that created it. All the text in a given update is keyed as
        'content', and updates that contain no text, eg: a change to the status
        of a bug, will contain the emtpy string instead.

        @param issue_id: The id of the issue we want detailed information on.
        @raises: ProjectHostingApiException, if the request execution fails.

        @return: A json formatted python dict that has an entry for each update
                 performed on this issue.
        """
        issue_comments = self._codesite_service.issues().comments()
        request = issue_comments.list(projectId=self._project_name,
                                      issueId=issue_id,
                                      maxResults=self._max_results_for_issue)
        return self._execute_request(request)


    def _get_issue(self, issue_id):
        """
        Gets an issue given it's id.

        @param issue_id: A string representing the issue id.
        @raises: ProjectHostingApiException, if failed to get the issue.

        @return: A json formatted python dict that has the issue content.
        """
        issues = self._codesite_service.issues()
        try:
            request = issues.get(projectId=self._project_name,
                                 issueId=issue_id)
        except TypeError as e:
            raise ProjectHostingApiException(
                'Unable to get issue %s from project %s: %s' %
                (issue_id, self._project_name, str(e)))
        return self._execute_request(request)


    def set_max_results(self, max_results):
        """Set the max results to return.

        @param max_results: An integer representing the maximum number of
            matching results to return per query.
        """
        self._max_results_for_issue = max_results


    def set_start_index(self, start_index):
        """Set the start index, for paging.

        @param start_index: The new start index to use.
        """
        self._start_index = start_index


    def list_issues(self, **kwargs):
        """
        List issues containing the search marker. This method will only list
        the summary, title and id of an issue, though it searches through the
        comments. Eg: if we're searching for the marker '123', issues that
        contain a comment of '123' will appear in the output, but the string
        '123' itself may not, because the output only contains issue summaries.

        @param kwargs:
            q: The anchor string used in the search.
            can: a string representing the search space that is passed to the
                 google api, can be 'all', 'new', 'open', 'owned', 'reported',
                 'starred', or 'to-verify', defaults to 'all'.
            label: A string representing a single label to match.

        @return: A json formatted python dict of all matching issues.

        @raises: ProjectHostingApiException, if the request execution fails.
        """
        issues = self._codesite_service.issues()

        # Asking for issues with None or '' labels will restrict the query
        # to those issues without labels.
        if not kwargs['label']:
            del kwargs['label']

        request = issues.list(projectId=self._project_name,
                              startIndex=self._start_index,
                              maxResults=self._max_results_for_issue,
                              **kwargs)
        return self._execute_request(request)


    def _get_property_values(self, prop_dict):
        """
        Searches a dictionary as returned by _get_field for property lists,
        then returns each value in the list. Effectively this gives us
        all the accepted values for a property. For example, in crosbug,
        'properties' map to things like Status, Labels, Owner etc, each of these
        will have a list within the issuesConfig dict.

        @param prop_dict: dictionary which contains a list of properties.
        @yield: each value in a property list. This can be a dict or any other
                type of datastructure, the caller is responsible for handling
                it correctly.
        """
        for name, property in prop_dict.iteritems():
            if isinstance(property, list):
                for values in property:
                    yield values


    def _get_cros_labels(self, prop_dict):
        """
        Helper function to isolate labels from the labels dictionary. This
        dictionary is of the form:
            {
                "label": "Cr-OS-foo",
                "description": "description"
            },
        And maps to the frontend like so:
            Labels: Cr-???
                    Cr-OS-foo = description
        where Cr-OS-foo is a conveniently predefined value for Label Cr-OS-???.

        @param prop_dict: a dictionary we expect the Cros label to be in.
        @return: A lower case product area, eg: video, factory, ui.
        """
        label = prop_dict.get('label')
        if label and 'Cr-OS-' in label:
            return label.split('Cr-OS-')[1]


    def get_areas(self):
        """
        Parse issue options and return a list of 'Cr-OS' labels.

        @return: a list of Cr-OS labels from crosbug, eg: ['kernel', 'systems']
        """
        if apiclient_build is None:
            logging.error('Missing Api-client import. Cannot get area-labels.')
            return []

        try:
            issue_options_dict = self._get_field('issuesConfig')
        except ProjectHostingApiException as e:
            logging.error('Unable to determine area labels: %s', str(e))
            return []

        # Since we can request multiple fields at once we need to
        # retrieve each one from the field options dictionary, even if we're
        # really only asking for one field.
        issue_options = issue_options_dict.get('issuesConfig')
        if issue_options is None:
            logging.error('The IssueConfig field does not contain issue '
                          'configuration as a member anymore; The project '
                          'hosting api might have changed.')
            return []

        return filter(None, [self._get_cros_labels(each)
                      for each in self._get_property_values(issue_options)
                      if isinstance(each, dict)])


    def create_issue(self, request_body):
        """
        Convert the request body into an issue on the frontend tracker.

        @param request_body: A python dictionary with key-value pairs
                             that represent the fields of the issue.
                             eg: {
                                'title': 'bug title',
                                'description': 'bug description',
                                'labels': ['Type-Bug'],
                                'owner': {'name': 'owner@'},
                                'cc': [{'name': 'cc1'}, {'name': 'cc2'}],
                                'components': ["Internals->Components"]
                             }
                             Note the title and descriptions fields of a
                             new bug are not optional, all other fields are.
        @raises: ProjectHostingApiException, if request execution fails.

        @return: The response body, which will contain the metadata of the
                 issue created, or an error response code and information
                 about a failure.
        """
        issues = self._codesite_service.issues()
        request = issues.insert(projectId=self._project_name, sendEmail=True,
                                body=request_body)
        return self._execute_request(request)


    def update_issue(self, issue_id, request_body):
        """
        Convert the request body into an update on an issue.

        @param request_body: A python dictionary with key-value pairs
                             that represent the fields of the update.
                             eg:
                             {
                                'content': 'comment to add',
                                'updates':
                                {
                                    'labels': ['Type-Bug', 'another label'],
                                    'owner': 'owner@',
                                    'cc': ['cc1@', cc2@'],
                                }
                             }
                             Note the owner and cc fields need to be email
                             addresses the tracker recognizes.
        @param issue_id: The id of the issue to update.
        @raises: ProjectHostingApiException, if request execution fails.

        @return: The response body, which will contain information about the
                 update of said issue, or an error response code and information
                 about a failure.
        """
        issues = self._codesite_service.issues()
        request = issues.comments().insert(projectId=self._project_name,
                                           issueId=issue_id, sendEmail=False,
                                           body=request_body)
        return self._execute_request(request)


    def _populate_issue_updates(self, t_issue):
        """
        Populates a tracker issue with updates.

        Any issue is useless without it's updates, since the updates will
        contain both the summary and the comments. We need at least one of
        those to successfully dedupe. The Api doesn't allow us to grab all this
        information in one shot because viewing the comments on an issue
        requires more authority than just viewing it's title.

        @param t_issue: The basic tracker issue, to populate with updates.
        @raises: ProjectHostingApiException, if request execution fails.

        @returns: A tracker issue, with it's updates.
        """
        updates = self._list_updates(t_issue['id'])
        t_issue['updates'] = [update['content'] for update in
                              self._get_property_values(updates)
                              if update.get('content')]
        return t_issue


    def get_tracker_issues_by_text(self, search_text, full_text=True,
                                   include_dupes=False, label=None):
        """
        Find all Tracker issues that contain the specified search text.

        @param search_text: Anchor text to use in the search.
        @param full_text: True if we would like an extensive search through
                          issue comments. If False the search will be restricted
                          to just summaries and titles.
        @param include_dupes: If True, search over both open issues as well as
                          closed issues whose status is 'Duplicate'. If False,
                          only search over open issues.
        @param label: A string representing a single label to match.

        @return: A list of issues that contain the search text, or an empty list
                 when we're either unable to list issues or none match the text.
        """
        issue_list = []
        try:
            search_space = 'all' if include_dupes else 'open'
            feed = self.list_issues(q=search_text, can=search_space,
                                    label=label)
        except ProjectHostingApiException as e:
            logging.error('Unable to search for issues with marker %s: %s',
                          search_text, e)
            return issue_list

        for t_issue in self._get_property_values(feed):
            state = t_issue.get(constants.ISSUE_STATE)
            status = t_issue.get(constants.ISSUE_STATUS)
            is_open_or_dup = (state == constants.ISSUE_OPEN or
                              (state == constants.ISSUE_CLOSED
                               and status == constants.ISSUE_DUPLICATE))
            # All valid issues will have an issue id we can use to retrieve
            # more information about it. If we encounter a failure mode that
            # returns a bad Http response code but doesn't throw an exception
            # we won't find an issue id in the returned json.
            if t_issue.get('id') and is_open_or_dup:
                # TODO(beeps): If this method turns into a performance
                # bottle neck yield each issue and refactor the reporter.
                # For now passing all issues allows us to detect when
                # deduping fails, because multiple issues will match a
                # given query exactly.
                try:
                    if full_text:
                        issue = Issue(self._populate_issue_updates(t_issue))
                    else:
                        issue = BaseIssue(t_issue)
                except ProjectHostingApiException as e:
                    logging.error('Unable to list the updates of issue %s: %s',
                                  t_issue.get('id'), str(e))
                else:
                    issue_list.append(issue)
        return issue_list


    def get_tracker_issue_by_id(self, issue_id):
        """
        Returns an issue object given the id.

        @param issue_id: A string representing the issue id.

        @return: An Issue object on success or None on failure.
        """
        try:
            t_issue = self._get_issue(issue_id)
            return Issue(self._populate_issue_updates(t_issue))
        except ProjectHostingApiException as e:
            logging.error('Creation of an Issue object for %s fails: %s',
                          issue_id, str(e))
            return None