#!/usr/bin/env python
# Copyright (c) 2012 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# pylint: disable=F0401

"""Routines to extract step information from builders.

This module mocks out enough of a Buildbot build system to extract BuildSteps
from builders. This is useful if you want to use these Buildsteps separate from
a Buildbot master.
"""

# pylint: disable=C0323,R0201

import os
import re
import sys

from common import chromium_utils

from buildbot.process import base
from buildbot.process import builder as real_builder
from buildbot.process.properties import Properties
from buildbot.status import build as build_module
from buildbot.status import builder
from buildbot.status.results import EXCEPTION
from buildbot.status.results import FAILURE
import buildbot.util
from buildslave.commands import registry
from buildslave.runprocess import shell_quote
from twisted.internet import defer
from twisted.internet import reactor
from twisted.python.reflect import accumulateClassList
from twisted.python.reflect import namedModule
from twisted.spread import pb
from twisted.spread import util


SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))


class FakeChange(object):
  """Represents a mock of a change to the source tree. See
  http://buildbot.net/buildbot/docs/0.8.5/reference/buildbot.changes.changes.
  Change-class.html for what I'm really supposed to be."""

  properties = Properties()
  who = 'me'


class FakeSource(object):
  """A mocked-up SourceStamp, which encapsulates all the parameters of the
  source checkout to build. See http://buildbot.net/buildbot/docs/latest/
  reference/buildbot.sourcestamp.SourceStamp-class.html for reference."""

  def __init__(self, setup):
    self.revision = setup.get('revision')
    self.branch = setup.get('branch')
    self.repository = setup.get('repository')
    self.project = setup.get('project')
    self.patch = setup.get('patch')
    self.changes = [FakeChange()]

    if not self.branch: self.branch = None
    if not self.revision:
      raise ValueError('must specify a revision!')


class FakeRequest(object):
  """A mocked-up BuildRequest, which encapsulates the parameters of the build.
  See http://buildbot.net/buildbot/docs/0.8.6/reference/buildbot.process.
  buildrequest.BuildRequest-class.html for reference."""

  reason = 'Because'
  properties = Properties()
  submittedAt = None

  def __init__(self, buildargs):
    self.source = FakeSource(buildargs)

  def mergeWith(self, others):
    return self.source

  def mergeReasons(self, others):
    return self.reason


class FakeSlave(util.LocalAsRemote):
  """A mocked combination of BuildSlave and SlaveBuilder. Controls the build
  by kicking off steps and receiving messages as those steps run. See
  http://buildbot.net/buildbot/docs/0.7.12/reference/buildbot.slave.bot.
  SlaveBuilder-class.html and http://buildbot.net/buildbot/docs/0.8.3/
  reference/buildbot.buildslave.BuildSlave-class.html for reference."""

  def __init__(self, builddir, slavebuilddir, slavename):
    self.slave = self
    self.properties = Properties()
    self.slave_basedir = '.'
    self.basedir = '.'  # this must be '.' since I combine slavebuilder
                        # and buildslave
    self.path_module = namedModule('posixpath')
    self.slavebuilddir = slavebuilddir or builddir
    self.builddir = builddir
    self.slavename = slavename
    self.usePTY = True
    self.updateactions = []
    self.unicode_encoding = 'utf8'
    self.command = None
    self.remoteStep = None

  def addUpdateAction(self, action):
    self.updateactions.append(action)

  def getSlaveCommandVersion(self, command, oldversion=None):
    return command

  def sendUpdate(self, data):
    for action in self.updateactions:
      action(data)
    self.remoteStep.remote_update([[data, 0]])

  def messageReceivedFromSlave(self):
    return None

  def sync_startCommand(self, stepref, stepId, command, cmdargs):
    try:
      cmdfactory = registry.getFactory(command)
    except KeyError:
      raise UnknownCommand("unrecognized SlaveCommand '%s'" % command)

    self.command = cmdfactory(self, stepId, cmdargs)

    self.remoteStep = stepref
    d = self.command.doStart()
    d.addCallback(stepref.remote_complete)
    return d


class UnknownCommand(pb.Error):
  """Represent an unknown slave command."""
  pass


class ReturnStatus(object):
  """Singleton needed for global return code."""

  def __init__(self):
    self.code = 0


def buildException(status, why):
  """Output error and stop further steps."""
  print >>sys.stderr, 'build error encountered:', why
  print >>sys.stderr, 'aborting build'
  status.code = 1
  reactor.callFromThread(reactor.stop)


def finished():
  """Tear down twisted session."""
  print >>sys.stderr, 'build completed successfully'
  reactor.callFromThread(reactor.stop)


def startNextStep(steps, run_status, prog_args):
  """Run the next step, optionally skipping if there is a stepfilter."""

  def getNextStep():
    if not steps:
      return None
    return steps.pop(0)
  try:
    s = getNextStep()
    if hasattr(prog_args, 'step_regex'):
      while s and not prog_args.step_regex.search(s.name):
        print >>sys.stderr, 'skipping step: ' + s.name
        s = getNextStep()
    if hasattr(prog_args, 'stepreject_regex'):
      while s and prog_args.stepreject_regex.search(s.name):
        print >>sys.stderr, 'skipping step: ' + s.name
        s = getNextStep()
  except StopIteration:
    s = None
  if not s:
    return finished()

  print >>sys.stderr, 'performing step: ' + s.name,
  s.step_status.stepStarted()
  d = defer.maybeDeferred(s.startStep, s.buildslave)
  d.addCallback(lambda x: checkStep(x, steps,
                                    run_status, prog_args))
  d.addErrback(lambda x: buildException(run_status, x))
  return d


def checkStep(rc, steps, run_status, prog_args):
  """Check if the previous step succeeded before continuing."""

  if (rc == FAILURE) or (rc == EXCEPTION):
    buildException(run_status, 'previous command failed')
  else:
    defer.maybeDeferred(lambda x: startNextStep(x,
                                                run_status, prog_args), steps)


def ListSteps(my_factory):
  """Construct a list of steps from the builder's factory."""
  steps = []
  stepnames = {}

  for factory, cmdargs in my_factory.steps:
    cmdargs = cmdargs.copy()
    try:
      step = factory(**cmdargs)
    except:
      print >>sys.stderr, ('error while creating step, factory=%s, args=%s'
                           % (factory, cmdargs))
      raise
    name = step.name
    if name in stepnames:
      count = stepnames[name]
      count += 1
      stepnames[name] = count
      name = step.name + ('_%d' % count)
    else:
      stepnames[name] = 0
    step.name = name

    # workdir is often silently passed through
    if 'workdir' in cmdargs:
      step.workdir = cmdargs['workdir']

    #TODO: is this a bug in FileUpload?
    if not hasattr(step, 'description') or not step.description:
      step.description = [step.name]
    if not hasattr(step, 'descriptionDone') or not step.descriptionDone:
      step.descriptionDone = [step.name]

    step.locks = []
    steps.append(step)

  return steps


class FakeMaster(object):
  def __init__(self, mastername):
    self.db = None
    self.master_name = mastername
    self.master_incarnation = None


class FakeBotmaster(object):
  def __init__(self, mastername, properties=Properties()):
    self.master = FakeMaster(mastername)
    self.parent = self
    self.properties = properties


def process_steps(steplist, build, buildslave, build_status, basedir):
  """Attach build and buildslaves to each step."""
  for step in steplist:
    step.setBuild(build)
    step.setBuildSlave(buildslave)
    step.setStepStatus(build_status.addStepWithName(step.name))
    step.setDefaultWorkdir(os.path.join(basedir, 'build'))
    if not hasattr(step, 'workdir') or not step.workdir:
      step.workdir = os.path.join(basedir, 'build')

    if not os.path.isabs(step.workdir):
      step.workdir = os.path.join(basedir, step.workdir)


def StripBuildrunnerIgnore(step):
  assert not step.name.endswith('_buildrunner_ignore_1'), (
      'Duplicate buildrunner step %s not allowed in %s' % (
          step.name.rstrip('_buildrunner_ignore_1'), step.build.builder.name))
  step.name = re.sub('_buildrunner_ignore$', '', step.name)


def GetCommands(steplist):
  """Extract shell commands from a step.

  Take the BuildSteps from MockBuild() and, if they inherit from ShellCommand,
  renders any renderables and extracts out the actual shell command to be
  executed. Returns a list of command hashes.
  """
  commands = []
  for step in steplist:
    cmdhash = {}
    StripBuildrunnerIgnore(step)
    cmdhash['name'] = step.name
    cmdhash['doStep'] = None
    cmdhash['stepclass'] = '%s.%s' % (step.__class__.__module__,
                                      step.__class__.__name__)
    if hasattr(step, 'command'):
      # None signifies this is not a buildrunner-added step.
      if step.brDoStepIf is None:
        doStep = step.brDoStepIf
      # doStep may modify build properties, so it must run before rendering.
      elif isinstance(step.brDoStepIf, bool):
        doStep = step.brDoStepIf
      else:
        doStep = step.brDoStepIf(step)

      renderables = []
      accumulateClassList(step.__class__, 'renderables', renderables)

      for renderable in renderables:
        setattr(step, renderable, step.build.render(getattr(step,
                renderable)))

      cmdhash['doStep'] = doStep
      cmdhash['command'] = step.command
      cmdhash['quoted_command'] = shell_quote(step.command)
      cmdhash['workdir'] = step.workdir
      cmdhash['quoted_workdir'] = shell_quote([step.workdir])
      cmdhash['haltOnFailure'] = step.haltOnFailure
      if hasattr(step, 'env'):
        cmdhash['env'] = step.env
      else:
        cmdhash['env'] = {}
      if hasattr(step, 'timeout'):
        cmdhash['timeout'] = step.timeout
      if hasattr(step, 'maxTime'):
        cmdhash['maxTime'] = step.maxTime

      cmdhash['description'] = step.description
      cmdhash['descriptionDone'] = step.descriptionDone
    commands.append(cmdhash)
  return commands


def MockBuild(my_builder, buildsetup, mastername, slavename, basepath=None,
              build_properties=None, slavedir=None):
  """Given a builder object and configuration, mock a Buildbot setup around it.

  This sets up a mock BuildMaster, BuildSlave, Build, BuildStatus, and all other
  superstructure required for BuildSteps inside the provided builder to render
  properly. These BuildSteps are returned to the user in an array. It
  additionally returns the build object (in order to get its properties if
  desired).

  buildsetup is passed straight into the FakeSource's init method and
  contains sourcestamp information (revision, branch, etc).

  basepath is the directory of the build (what goes under build/slave/, for
  example 'Chromium_Linux_Builder'. It is nominally inferred from the builder
  name, but it can be overridden. This is useful when pointing the buildrunner
  at a different builder than what it's running under.

  build_properties will update and override build_properties after all
  builder-derived defaults have been set.
  """

  my_factory = my_builder['factory']
  steplist = ListSteps(my_factory)

  build = base.Build([FakeRequest(buildsetup)])
  safename = buildbot.util.safeTranslate(my_builder['name'])

  my_builder.setdefault('builddir', safename)
  my_builder.setdefault('slavebuilddir', my_builder['builddir'])


  workdir_root = None
  if not slavedir:
    workdir_root = os.path.join(SCRIPT_DIR, '..', '..', 'slave',
                                my_builder['slavebuilddir'])

  if not basepath: basepath = safename
  if not slavedir: slavedir = os.path.join(SCRIPT_DIR,
                                           '..', '..', 'slave')
  basedir = os.path.join(slavedir, basepath)
  build.basedir = basedir
  if not workdir_root:
    workdir_root = basedir

  builderstatus = builder.BuilderStatus('test')
  builderstatus.basedir = basedir
  buildnumber = build_properties.get('buildnumber', 1)
  builderstatus.nextBuildNumber = buildnumber + 1

  mybuilder = real_builder.Builder(my_builder, builderstatus)
  build.setBuilder(mybuilder)
  build_status = build_module.BuildStatus(builderstatus, buildnumber)

  build_status.setProperty('blamelist', [], 'Build')
  build_status.setProperty('mastername', mastername, 'Build')
  build_status.setProperty('slavename', slavename, 'Build')
  build_status.setProperty('gtest_filter', [], 'Build')
  build_status.setProperty('extra_args', [], 'Build')
  build_status.setProperty('build_id', buildnumber, 'Build')

  # if build_properties are passed in, overwrite the defaults above:
  buildprops = Properties()
  if build_properties:
    buildprops.update(build_properties, 'Botmaster')
  mybuilder.setBotmaster(FakeBotmaster(mastername, buildprops))

  buildslave = FakeSlave(safename, my_builder.get('slavebuilddir'), slavename)
  build.build_status = build_status
  build.setupSlaveBuilder(buildslave)
  build.setupProperties()
  process_steps(steplist, build, buildslave, build_status, workdir_root)

  return steplist, build
