# -*- coding: utf-8 -*-
# Copyright (c) 2011 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.

"""Classes for collecting results of our BuildStages as they run."""

from __future__ import print_function

import collections
import datetime
import math
import os

from autotest_lib.utils.frozen_chromite.lib import constants
from autotest_lib.utils.frozen_chromite.lib import failures_lib
from autotest_lib.utils.frozen_chromite.lib import cros_build_lib
from autotest_lib.utils.frozen_chromite.lib import cros_logging as logging

def _GetCheckpointFile(buildroot):
  return os.path.join(buildroot, '.completed_stages')


def WriteCheckpoint(buildroot):
  """Drops a completed stages file with current state."""
  completed_stages_file = _GetCheckpointFile(buildroot)
  with open(completed_stages_file, 'w+') as save_file:
    Results.SaveCompletedStages(save_file)


def LoadCheckpoint(buildroot):
  """Restore completed stage info from checkpoint file."""
  completed_stages_file = _GetCheckpointFile(buildroot)
  if not os.path.exists(completed_stages_file):
    logging.warning('Checkpoint file not found in buildroot %s', buildroot)
    return

  with open(completed_stages_file, 'r') as load_file:
    Results.RestoreCompletedStages(load_file)


class RecordedTraceback(object):
  """This class represents a traceback recorded in the list of results."""

  def __init__(self, failed_stage, failed_prefix, exception, traceback):
    """Construct a RecordedTraceback object.

    Args:
      failed_stage: The stage that failed during the build. E.g., HWTest [bvt]
      failed_prefix: The prefix of the stage that failed. E.g., HWTest
      exception: The raw exception object.
      traceback: The full stack trace for the failure, as a string.
    """
    self.failed_stage = failed_stage
    self.failed_prefix = failed_prefix
    self.exception = exception
    self.traceback = traceback


_result_fields = ['name', 'result', 'description', 'prefix', 'board', 'time']
Result = collections.namedtuple('Result', _result_fields)


class _Results(object):
  """Static class that collects the results of our BuildStages as they run."""

  SUCCESS = 'Stage was successful'
  FORGIVEN = 'Stage failed but was optional'
  SKIPPED = 'Stage was skipped'
  NON_FAILURE_TYPES = (SUCCESS, FORGIVEN, SKIPPED)

  SPLIT_TOKEN = r'\_O_/'

  def __init__(self):
    # List of results for all stages that's built up as we run. Members are of
    #  the form ('name', SUCCESS | FORGIVEN | Exception, None | description)
    self._results_log = []

    # A list of instances of failure_message_lib.StageFailureMessage to present
    # the exceptions threw by failed stages.
    self._failure_message_results = []

    # Stages run in a previous run and restored. Stored as a dictionary of
    # names to previous records.
    self._previous = {}

    self.start_time = datetime.datetime.now()

  def Clear(self):
    """Clear existing stage results."""
    self.__init__()

  def PreviouslyCompletedRecord(self, name):
    """Check to see if this stage was previously completed.

    Returns:
      A boolean showing the stage was successful in the previous run.
    """
    return self._previous.get(name)

  def BuildSucceededSoFar(self, buildstore=None, buildbucket_id=None,
                          name=None):
    """Return true if all stages so far have passing states.

    This method returns true if all was successful or forgiven or skipped.

    Args:
      buildstore: A BuildStore instance to make DB calls.
      buildbucket_id: buildbucket_id of the build to check.
      name: stage name of current stage.
    """
    build_succeess = all(entry.result in self.NON_FAILURE_TYPES
                         for entry in self._results_log)

    # When timeout happens and background tasks are killed, the statuses
    # of the background stage tasks may get lost. BuildSucceededSoFar may
    # still return build_succeess = True when the killed stage tasks were
    # failed. Add one more verification step in _BuildSucceededFromCIDB to
    # check the stage status in CIDB.
    return (build_succeess and
            self._BuildSucceededFromCIDB(buildstore=buildstore,
                                         buildbucket_id=buildbucket_id,
                                         name=name))

  def _BuildSucceededFromCIDB(self, buildstore=None, buildbucket_id=None,
                              name=None):
    """Return True if all stages recorded in buildbucket have passing states.

    Args:
      buildstore: A BuildStore instance to make DB calls.
      buildbucket_id: buildbucket_id of the build to check.
      name: stage name of current stage.
    """
    if (buildstore is not None and buildstore.AreClientsReady()
        and buildbucket_id is not None):
      stages = buildstore.GetBuildsStages(buildbucket_ids=[buildbucket_id])
      for stage in stages:
        if name is not None and stage['name'] == name:
          logging.info("Ignore status of %s as it's the current stage.",
                       stage['name'])
          continue
        if stage['status'] not in constants.BUILDER_NON_FAILURE_STATUSES:
          logging.warning('Failure in previous stage %s with status %s.',
                          stage['name'], stage['status'])
          return False

    return True

  def StageHasResults(self, name):
    """Return true if stage has posted results."""
    return name in [entry.name for entry in self._results_log]

  def _RecordStageFailureMessage(self, name, exception, prefix=None,
                                 build_stage_id=None):
    self._failure_message_results.append(
        failures_lib.GetStageFailureMessageFromException(
            name, build_stage_id, exception, stage_prefix_name=prefix))

  def Record(self, name, result, description=None, prefix=None, board='',
             time=0, build_stage_id=None):
    """Store off an additional stage result.

    Args:
      name: The name of the stage (e.g. HWTest [bvt])
      result:
        Result should be one of:
          Results.SUCCESS if the stage was successful.
          Results.SKIPPED if the stage was skipped.
          Results.FORGIVEN if the stage had warnings.
          Otherwise, it should be the exception stage errored with.
      description:
        The textual backtrace of the exception, or None
      prefix: The prefix of the stage (e.g. HWTest). Defaults to
        the value of name.
      board: The board associated with the stage, if any. Defaults to ''.
      time: How long the result took to complete.
      build_stage_id: The id of the failed build stage to record, default to
        None.
    """
    if prefix is None:
      prefix = name

    # Convert exception to stage_failure_message and record it.
    if isinstance(result, BaseException):
      self._RecordStageFailureMessage(name, result, prefix=prefix,
                                      build_stage_id=build_stage_id)

    result = Result(name, result, description, prefix, board, time)
    self._results_log.append(result)

  def GetStageFailureMessage(self):
    return self._failure_message_results

  def Get(self):
    """Fetch stage results.

    Returns:
      A list with one entry per stage run with a result.
    """
    return self._results_log

  def GetPrevious(self):
    """Fetch stage results.

    Returns:
      A list of stages names that were completed in a previous run.
    """
    return self._previous

  def SaveCompletedStages(self, out):
    """Save the successfully completed stages to the provided file |out|."""
    for entry in self._results_log:
      if entry.result != self.SUCCESS:
        break
      out.write(self.SPLIT_TOKEN.join(str(x) for x in entry) + '\n')

  def RestoreCompletedStages(self, out):
    """Load the successfully completed stages from the provided file |out|."""
    # Read the file, and strip off the newlines.
    for line in out:
      record = line.strip().split(self.SPLIT_TOKEN)
      if len(record) != len(_result_fields):
        logging.warning('State file does not match expected format, ignoring.')
        # Wipe any partial state.
        self._previous = {}
        break

      self._previous[record[0]] = Result(*record)

  def GetTracebacks(self):
    """Get a list of the exceptions that failed the build.

    Returns:
      A list of RecordedTraceback objects.
    """
    tracebacks = []
    for entry in self._results_log:
      # If entry.result is not in NON_FAILURE_TYPES, then the stage failed, and
      # entry.result is the exception object and entry.description is a string
      # containing the full traceback.
      if entry.result not in self.NON_FAILURE_TYPES:
        traceback = RecordedTraceback(entry.name, entry.prefix, entry.result,
                                      entry.description)
        tracebacks.append(traceback)
    return tracebacks

  def Report(self, out, current_version=None):
    """Generate a user friendly text display of the results data.

    Args:
      out: Output stream to write to (e.g. sys.stdout).
      current_version: ChromeOS version associated with this report.
    """
    results = self._results_log

    line = '*' * 60 + '\n'
    edge = '*' * 2

    if current_version:
      out.write(line)
      out.write(edge +
                ' RELEASE VERSION: ' +
                current_version +
                '\n')

    out.write(line)
    out.write(edge + ' Stage Results\n')
    warnings = False

    for entry in results:
      name, result, run_time = (entry.name, entry.result, entry.time)
      timestr = datetime.timedelta(seconds=math.ceil(run_time))

      # Don't print data on skipped stages.
      if result == self.SKIPPED:
        continue

      out.write(line)
      details = ''
      if result == self.SUCCESS:
        status = 'PASS'
      elif result == self.FORGIVEN:
        status = 'FAILED BUT FORGIVEN'
        warnings = True
      else:
        status = 'FAIL'
        if isinstance(result, cros_build_lib.RunCommandError):
          # If there was a run error, give just the command that failed, not
          # its full argument list, since those are usually too long.
          details = ' in %s' % result.result.cmd[0]
        elif isinstance(result, failures_lib.BuildScriptFailure):
          # BuildScriptFailure errors publish a 'short' name of the
          # command that failed.
          details = ' in %s' % result.shortname
        else:
          # There was a normal error. Give the type of exception.
          details = ' with %s' % type(result).__name__

      out.write('%s %s %s (%s)%s\n' % (edge, status, name, timestr, details))

    out.write(line)

    for x in self.GetTracebacks():
      if x.failed_stage and x.traceback:
        out.write('\nFailed in stage %s:\n\n' % x.failed_stage)
        out.write(x.traceback)
        out.write('\n')

    if warnings:
      logging.PrintBuildbotStepWarnings(out)


Results = _Results()
