# Copyright 2011 Google Inc. All Rights Reserved.
# Author: kbaclawski@google.com (Krystian Baclawski)
#

from collections import defaultdict
from collections import namedtuple
from datetime import datetime
from fnmatch import fnmatch
from itertools import groupby
import logging
import os.path
import re


class DejaGnuTestResult(namedtuple('Result', 'name variant result flaky')):
  """Stores the result of a single test case."""

  # avoid adding __dict__ to the class
  __slots__ = ()

  LINE_RE = re.compile(r'([A-Z]+):\s+([\w/+.-]+)(.*)')

  @classmethod
  def FromLine(cls, line):
    """Alternate constructor which takes a string and parses it."""
    try:
      attrs, line = line.split('|', 1)

      if attrs.strip() != 'flaky':
        return None

      line = line.strip()
      flaky = True
    except ValueError:
      flaky = False

    fields = cls.LINE_RE.match(line.strip())

    if fields:
      result, path, variant = fields.groups()

      # some of the tests are generated in build dir and are issued from there,
      # because every test run is performed in randomly named tmp directory we
      # need to remove random part
      try:
        # assume that 2nd field is a test path
        path_parts = path.split('/')

        index = path_parts.index('testsuite')
        path = '/'.join(path_parts[index + 1:])
      except ValueError:
        path = '/'.join(path_parts)

      # Remove junk from test description.
      variant = variant.strip(', ')

      substitutions = [
          # remove include paths - they contain name of tmp directory
          ('-I\S+', ''),
          # compress white spaces
          ('\s+', ' ')
      ]

      for pattern, replacement in substitutions:
        variant = re.sub(pattern, replacement, variant)

      # Some tests separate last component of path by space, so actual filename
      # ends up in description instead of path part. Correct that.
      try:
        first, rest = variant.split(' ', 1)
      except ValueError:
        pass
      else:
        if first.endswith('.o'):
          path = os.path.join(path, first)
          variant = rest

      # DejaGNU framework errors don't contain path part at all, so description
      # part has to be reconstructed.
      if not any(os.path.basename(path).endswith('.%s' % suffix)
                 for suffix in ['h', 'c', 'C', 'S', 'H', 'cc', 'i', 'o']):
        variant = '%s %s' % (path, variant)
        path = ''

      # Some tests are picked up from current directory (presumably DejaGNU
      # generates some test files). Remove the prefix for these files.
      if path.startswith('./'):
        path = path[2:]

      return cls(path, variant or '', result, flaky=flaky)

  def __str__(self):
    """Returns string representation of a test result."""
    if self.flaky:
      fmt = 'flaky | '
    else:
      fmt = ''
    fmt += '{2}: {0}'
    if self.variant:
      fmt += ' {1}'
    return fmt.format(*self)


class DejaGnuTestRun(object):
  """Container for test results that were a part of single test run.

  The class stores also metadata related to the test run.

  Attributes:
    board: Name of DejaGNU board, which was used to run the tests.
    date: The date when the test run was started.
    target: Target triple.
    host: Host triple.
    tool: The tool that was tested (e.g. gcc, binutils, g++, etc.)
    results: a list of DejaGnuTestResult objects.
  """

  __slots__ = ('board', 'date', 'target', 'host', 'tool', 'results')

  def __init__(self, **kwargs):
    assert all(name in self.__slots__ for name in kwargs)

    self.results = set()
    self.date = kwargs.get('date', datetime.now())

    for name in ('board', 'target', 'tool', 'host'):
      setattr(self, name, kwargs.get(name, 'unknown'))

  @classmethod
  def FromFile(cls, filename):
    """Alternate constructor - reads a DejaGNU output file."""
    test_run = cls()
    test_run.FromDejaGnuOutput(filename)
    test_run.CleanUpTestResults()
    return test_run

  @property
  def summary(self):
    """Returns a summary as {ResultType -> Count} dictionary."""
    summary = defaultdict(int)

    for r in self.results:
      summary[r.result] += 1

    return summary

  def _ParseBoard(self, fields):
    self.board = fields.group(1).strip()

  def _ParseDate(self, fields):
    self.date = datetime.strptime(fields.group(2).strip(), '%a %b %d %X %Y')

  def _ParseTarget(self, fields):
    self.target = fields.group(2).strip()

  def _ParseHost(self, fields):
    self.host = fields.group(2).strip()

  def _ParseTool(self, fields):
    self.tool = fields.group(1).strip()

  def FromDejaGnuOutput(self, filename):
    """Read in and parse DejaGNU output file."""

    logging.info('Reading "%s" DejaGNU output file.', filename)

    with open(filename, 'r') as report:
      lines = [line.strip() for line in report.readlines() if line.strip()]

    parsers = ((re.compile(r'Running target (.*)'), self._ParseBoard),
               (re.compile(r'Test Run By (.*) on (.*)'), self._ParseDate),
               (re.compile(r'=== (.*) tests ==='), self._ParseTool),
               (re.compile(r'Target(\s+)is (.*)'), self._ParseTarget),
               (re.compile(r'Host(\s+)is (.*)'), self._ParseHost))

    for line in lines:
      result = DejaGnuTestResult.FromLine(line)

      if result:
        self.results.add(result)
      else:
        for regexp, parser in parsers:
          fields = regexp.match(line)
          if fields:
            parser(fields)
            break

    logging.debug('DejaGNU output file parsed successfully.')
    logging.debug(self)

  def CleanUpTestResults(self):
    """Remove certain test results considered to be spurious.

    1) Large number of test reported as UNSUPPORTED are also marked as
       UNRESOLVED. If that's the case remove latter result.
    2) If a test is performed on compiler output and for some reason compiler
       fails, we don't want to report all failures that depend on the former.
    """
    name_key = lambda v: v.name
    results_by_name = sorted(self.results, key=name_key)

    for name, res_iter in groupby(results_by_name, key=name_key):
      results = set(res_iter)

      # If DejaGnu was unable to compile a test it will create following result:
      failed = DejaGnuTestResult(name, '(test for excess errors)', 'FAIL',
                                 False)

      # If a test compilation failed, remove all results that are dependent.
      if failed in results:
        dependants = set(filter(lambda r: r.result != 'FAIL', results))

        self.results -= dependants

        for res in dependants:
          logging.info('Removed {%s} dependance.', res)

      # Remove all UNRESOLVED results that were also marked as UNSUPPORTED.
      unresolved = [res._replace(result='UNRESOLVED')
                    for res in results if res.result == 'UNSUPPORTED']

      for res in unresolved:
        if res in self.results:
          self.results.remove(res)
          logging.info('Removed {%s} duplicate.', res)

  def _IsApplicable(self, manifest):
    """Checks if test results need to be reconsidered based on the manifest."""
    check_list = [(self.tool, manifest.tool), (self.board, manifest.board)]

    return all(fnmatch(text, pattern) for text, pattern in check_list)

  def SuppressTestResults(self, manifests):
    """Suppresses all test results listed in manifests."""

    # Get a set of tests results that are going to be suppressed if they fail.
    manifest_results = set()

    for manifest in filter(self._IsApplicable, manifests):
      manifest_results |= set(manifest.results)

    suppressed_results = self.results & manifest_results

    for result in sorted(suppressed_results):
      logging.debug('Result suppressed for {%s}.', result)

      new_result = '!' + result.result

      # Mark result suppression as applied.
      manifest_results.remove(result)

      # Rewrite test result.
      self.results.remove(result)
      self.results.add(result._replace(result=new_result))

    for result in sorted(manifest_results):
      logging.warning('Result {%s} listed in manifest but not suppressed.',
                      result)

  def __str__(self):
    return '{0}, {1} @{2} on {3}'.format(self.target, self.tool, self.board,
                                         self.date)