"""
Aggregate coverage data from XML reports.

groups.json is a JSON-encoded dict mapping group names to source file glob patterns:
{
    "group_1": "group1/*.py",
    "group_2": "group2/*.py"
}

This would calculate line coverage percentages for source files in each group,
and send those metrics to DataDog:

testeng.coverage.group_1 ==> 89.123
testeng.coverage.group_2 ==> 45.523

The tool uses the *union* of covered lines across each of the input
coverage XML reports.  If a line is covered *anywhere*, it's considered covered.
"""

import fnmatch
import json
from lxml import etree


class CoverageParseError(Exception):
    """
    Error occurred while parsing a coverage report.
    """
    pass


class CoverageData(object):
    """
    Aggregate coverage reports.
    """

    def __init__(self):
        """
        Initialize the coverage data, which has no information until you add a report.
        """
        self._coverage = dict()

    def add_report(self, report_str):
        """
        Add the coverage information from the XML `report_str` to the aggregate data.
        Raises a `CoverageParseError` if the report XML is not a valid coverage report.
        """
        try:
            root = etree.fromstring(report_str)

        except etree.XMLSyntaxError:
            raise CoverageParseError("Warning: Could not parse report as XML")

        if root is not None:

            # Get all classes (source files) in the report
            for class_node in root.xpath('//class'):

                class_filename = class_node.get('filename')

                if class_filename is None:
                    continue

                # If we haven't seen this source file before, create a dict
                # to store its coverage information.
                if class_filename not in self._coverage:
                    self._coverage[class_filename] = dict()

                # Store info for each line in the source file
                for line in class_node.xpath('lines/line'):

                    hits = line.get('hits')
                    line_num = line.get('number')

                    # Ignore lines that do not have the right attributes
                    if line_num is not None:

                        try:
                            line_num = int(line_num)
                            hits = int(hits)

                        except ValueError:
                            pass

                        else:

                            # If any report says the line is covered, set it to covered
                            if hits > 0:
                                self._coverage[class_filename][line_num] = 1

                            # Otherwise if the line is not already covered, set it to uncovered
                            elif line_num not in self._coverage[class_filename]:
                                self._coverage[class_filename][line_num] = 0

    def coverage(self, source_pattern="*"):
        """
        Calculate line coverage percentage (float) for source files that match
        `source_pattern` (a fnmatch-style glob pattern).

        If coverage could not be calculated (e.g. because no source files match
        the pattern), returns None.
        """
        num_covered = 0
        total = 0

        # Find source files that match the pattern then calculate total lines and number covered
        for filename in fnmatch.filter(self._coverage.keys(), source_pattern):
            num_covered += sum(self._coverage[filename].values())
            total += len(self._coverage[filename])

        # Calculate the percentage
        if total > 0:
            return float(num_covered) / float(total) * 100.0

        else:
            print u"Warning: No lines found in source files that match {}".format(source_pattern)
            return None

    @staticmethod
    def _parse_report(report_path):
        """
        Parse the coverage report as XML and return the resulting tree.
        If the report could not be found or parsed, return None.
        """
        try:
            return etree.parse(report_path)

        except IOError:
            print u"Warning: Could not open report at '{path}'".format(path=report_path)
            return None

        except ValueError:
            print u"Warning: Could not parse report at '{path}' as XML".format(path=report_path)
            return None


class CoverageMetrics(object):
    """
    Collect Coverage Reports for DataDog.
    """

    def __init__(self, group_json_path, report_paths):
        self._group_json_path = group_json_path
        self._report_paths = report_paths

    def coverage_metrics(self):
        """
        Find, parse, and create coverage metrics to be sent to DataDog.
        """
        print "Loading group definitions..."
        group_dict = self.load_group_defs(self._group_json_path)

        print "Parsing reports..."
        metrics = self.parse_reports(self._report_paths)

        print "Creating metrics..."
        stats = self.create_metrics(metrics, group_dict)
        print "Done."

        return stats

    @staticmethod
    def load_group_defs(group_json_path):
        """
        Load the dictionary mapping group names to source file patterns
        from the file located at `group_json_path`.

        Exits with an error message if the groups could not be parsed.
        """
        try:
            with open(group_json_path) as json_file:
                return json.load(json_file)

        except IOError:
            print u"Could not open group definition file at '{}'".format(group_json_path)
            raise

        except ValueError:
            print u"Could not parse group definitions in '{}'".format(group_json_path)
            raise

    @staticmethod
    def parse_reports(report_paths):
        """
        Parses each coverage report in `report_paths` and returns
        a `CoverageData` object containing the aggregate coverage information.
        """
        data = CoverageData()

        for path in report_paths:

            try:
                with open(path) as report_file:
                    data.add_report(report_file.read())

            except IOError:
                print u"Warning: could not open {}".format(path)

            except CoverageParseError:
                print u"Warning: could not parse {} as an XML coverage report".format(path)

        return data

    @staticmethod
    def create_metrics(data, groups):
        """
        Given a `CoverageData` object, create coverage percentages for each group.

        `groups` is a dict mapping aggregate group names to source file patterns.
        Group names are used in the name of the metric sent to DataDog.
        """
        metrics = {}
        for group_name, pattern in groups.iteritems():
            metric = 'test_eng.coverage.{group}'.format(group=group_name.replace(' ', '_'))
            percent = data.coverage(pattern)

            if percent is not None:
                print u"Sending {} ==> {}%".format(metric, percent)
                metrics[metric] = percent

        return metrics