#!/usr/bin/python2.4
# Copyright 2008, Google Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
#     * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#     * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following disclaimer
# in the documentation and/or other materials provided with the
# distribution.
#     * Neither the name of Google Inc. nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""Software construction toolkit builders for SCons."""


import SCons


__component_list = {}


def _InitializeComponentBuilders(env):
  """Re-initializes component builders module.

  Args:
    env: Environment context
  """
  env = env     # Silence gpylint

  __component_list.clear()


def _RetrieveComponents(component_name, filter_components=None):
  """Get the list of all components required by the specified component.

  Args:
    component_name: Name of the base component.
    filter_components: List of components NOT to include.

  Returns:
    A list of the transitive closure of all components required by the base
    component.  That is, if A requires B and B requires C, this returns [B, C].

  """
  if filter_components:
    filter_components = set(filter_components)
  else:
    filter_components = set()

  components = set([component_name])    # Components always require themselves
  new_components = set(components)
  while new_components:
    # Take next new component and add it to the list we've already scanned.
    c = new_components.pop()
    components.add(c)
    # Add to the list of new components any of c's components that we haven't
    # seen before.
    new_components.update(__component_list.get(c, set())
                          - components - filter_components)

  return list(components)


def _StoreComponents(self, component_name):
  """Stores the list of child components for the specified component.

  Args:
    self: Environment containing component.
    component_name: Name of the component.

  Adds component references based on the LIBS and COMPONENTS variables in the
  current environment.  Should be called at primary SConscript execution time;
  use _RetrieveComponents() to get the final components lists in a Defer()'d
  function.
  """

  components = set()
  for clist in ('LIBS', 'COMPONENTS'):
    components.update(map(self.subst, self.Flatten(self[clist])))

  if component_name not in __component_list:
    __component_list[component_name] = set()
  __component_list[component_name].update(components)


def _ComponentPlatformSetup(env, builder_name, **kwargs):
  """Modify an environment to work with a component builder.

  Args:
    env: Environment to clone.
    builder_name: Name of the builder.
    kwargs: Keyword arguments.

  Returns:
    A modified clone of the environment.
  """
  # Clone environment so we can modify it
  env = env.Clone()

  # Add all keyword arguments to the environment
  for k, v in kwargs.items():
    env[k] = v

  # Add compiler flags for included headers, if any
  env['INCLUDES'] = env.Flatten(env.subst_list(['$INCLUDES']))
  for h in env['INCLUDES']:
    env.Append(CCFLAGS = ['${CCFLAG_INCLUDE}%s' % h])

  # Call platform-specific component setup function, if any
  if env.get('COMPONENT_PLATFORM_SETUP'):
    env['COMPONENT_PLATFORM_SETUP'](env, builder_name)

  # Return the modified environment
  return env

#------------------------------------------------------------------------------

# TODO(rspangler): Should be possible to refactor programs, test programs,
# libs to all publish as packages, for simplicity and code reuse.


def ComponentPackageDeferred(env):
  """Deferred build steps for component package.

  Args:
    env: Environment from ComponentPackage().

  Sets up the aliases to build the package.
  """
  package_name = env['PACKAGE_NAME']

  # Install program and resources
  all_outputs = []
  filter = env.Flatten(env.subst_list('$COMPONENT_PACKAGE_FILTER'))
  components = _RetrieveComponents(package_name, filter)
  for resource, dest_dir in env.get('COMPONENT_PACKAGE_RESOURCES').items():
    all_outputs += env.ReplicatePublished(dest_dir, components, resource)

  # Add installed program and resources to the alias
  env.Alias(package_name, all_outputs)


def ComponentPackage(self, package_name, dest_dir, **kwargs):
  """Pseudo-builder for package containing other components.

  Args:
    self: Environment in which we were called.
    package_name: Name of package.
    dest_dir: Destination directory for package.
    args: Positional arguments.
    kwargs: Keyword arguments.

  Returns:
    The alias node for the package.
  """
  # Clone and modify environment
  env = _ComponentPlatformSetup(self, 'ComponentPackage', **kwargs)

  env.Replace(
      PACKAGE_NAME=package_name,
      PACKAGE_DIR=dest_dir,
  )

  # Add an empty alias for the package and add it to the right groups
  a = env.Alias(package_name, [])
  for group in env['COMPONENT_PACKAGE_GROUPS']:
    SCons.Script.Alias(group, a)

  # Store list of components for this program
  env._StoreComponents(package_name)

  # Let component_targets know this target is available in the current mode
  env.SetTargetProperty(package_name, TARGET_PATH=dest_dir)

  # Set up deferred call to replicate resources
  env.Defer(ComponentPackageDeferred)

  # Return the alias, since it's the only node we have
  return a

#------------------------------------------------------------------------------


def ComponentObject(self, *args, **kwargs):
  """Pseudo-builder for object to handle platform-dependent type.

  Args:
    self: Environment in which we were called.
    args: Positional arguments.
    kwargs: Keyword arguments.

  Returns:
    Passthrough return code from env.StaticLibrary() or env.SharedLibrary().

  TODO(rspangler): Perhaps this should be a generator builder, so it can take
  a list of inputs and return a list of outputs?
  """
  # Clone and modify environment
  env = _ComponentPlatformSetup(self, 'ComponentObject', **kwargs)

  # Make appropriate object type
  if env.get('COMPONENT_STATIC'):
    o = env.StaticObject(*args, **kwargs)
  else:
    o = env.SharedObject(*args, **kwargs)

  # Add dependencies on includes
  env.Depends(o, env['INCLUDES'])

  return o

#------------------------------------------------------------------------------


def ComponentLibrary(self, lib_name, *args, **kwargs):
  """Pseudo-builder for library to handle platform-dependent type.

  Args:
    self: Environment in which we were called.
    lib_name: Library name.
    args: Positional arguments.
    kwargs: Keyword arguments.

  Returns:
    Passthrough return code from env.StaticLibrary() or env.SharedLibrary().
  """
  # Clone and modify environment
  env = _ComponentPlatformSetup(self, 'ComponentLibrary', **kwargs)

  # Make appropriate library type
  if env.get('COMPONENT_STATIC'):
    lib_outputs = env.StaticLibrary(lib_name, *args, **kwargs)
  else:
    lib_outputs = env.SharedLibrary(lib_name, *args, **kwargs)

  # Add dependencies on includes
  env.Depends(lib_outputs, env['INCLUDES'])

  # Scan library outputs for files we need to link against this library, and
  # files we need to run executables linked against this library.
  need_for_link = []
  need_for_debug = []
  need_for_run = []
  for o in lib_outputs:
    if o.suffix in env['COMPONENT_LIBRARY_LINK_SUFFIXES']:
      need_for_link.append(o)
    if o.suffix in env['COMPONENT_LIBRARY_DEBUG_SUFFIXES']:
      need_for_debug.append(o)
    if o.suffix == env['SHLIBSUFFIX']:
      need_for_run.append(o)
  all_outputs = lib_outputs

  # Install library in intermediate directory, so other libs and programs can
  # link against it
  all_outputs += env.Replicate('$LIB_DIR', need_for_link)

  # Publish output
  env.Publish(lib_name, 'run', need_for_run)
  env.Publish(lib_name, 'debug', need_for_debug)

  # Add an alias to build and copy the library, and add it to the right groups
  a = self.Alias(lib_name, all_outputs)
  for group in env['COMPONENT_LIBRARY_GROUPS']:
    SCons.Script.Alias(group, a)

  # Store list of components for this library
  env._StoreComponents(lib_name)

  # Let component_targets know this target is available in the current mode.
  env.SetTargetProperty(lib_name, TARGET_PATH=lib_outputs[0])

  # If library should publish itself, publish as if it was a program
  if env.get('COMPONENT_LIBRARY_PUBLISH'):
    env['PROGRAM_BASENAME'] = lib_name
    env.Defer(ComponentProgramDeferred)

  # Return the library outputs
  return lib_outputs

#------------------------------------------------------------------------------


def ComponentTestProgramDeferred(env):
  """Deferred build steps for test program.

  Args:
    env: Environment from ComponentTestProgram().

  Sets up the aliases to compile and run the test program.
  """
  prog_name = env['PROGRAM_BASENAME']

  # Install program and resources
  all_outputs = []
  components = _RetrieveComponents(prog_name)
  for resource, dest_dir in env.get('COMPONENT_TEST_RESOURCES').items():
    all_outputs += env.ReplicatePublished(dest_dir, components, resource)

  # Add installed program and resources to the alias
  env.Alias(prog_name, all_outputs)

  # Add target properties
  env.SetTargetProperty(
      prog_name,
      # The copy of the program we care about is the one in the tests dir
      EXE='$TESTS_DIR/$PROGRAM_NAME',
      RUN_CMDLINE='$COMPONENT_TEST_CMDLINE',
      RUN_DIR='$TESTS_DIR',
      TARGET_PATH='$TESTS_DIR/$PROGRAM_NAME',
  )

  # Add an alias for running the test in the test directory, if the test is
  # runnable and has a test command line.
  if env.get('COMPONENT_TEST_RUNNABLE') and env.get('COMPONENT_TEST_CMDLINE'):
    env.Replace(
        COMMAND_OUTPUT_CMDLINE=env['COMPONENT_TEST_CMDLINE'],
        COMMAND_OUTPUT_RUN_DIR='$TESTS_DIR',
    )
    test_out_name = '$TEST_OUTPUT_DIR/${PROGRAM_BASENAME}.out.txt'
    if (env.GetOption('component_test_retest')
        and env.File(test_out_name).exists()):
      # Delete old test results, so test will rerun.
      env.Execute(SCons.Script.Delete(test_out_name))

    # Set timeout based on test size
    timeout = env.get('COMPONENT_TEST_TIMEOUT')
    if type(timeout) is dict:
      timeout = timeout.get(env.get('COMPONENT_TEST_SIZE'))
    if timeout:
      env['COMMAND_OUTPUT_TIMEOUT'] = timeout

    # Test program is the first run resource we replicated.  (Duplicate
    # replicate is not harmful, and is a handy way to pick out the correct
    # file from all those we replicated above.)
    test_program = env.ReplicatePublished('$TESTS_DIR', prog_name, 'run')

    # Run the test.  Note that we need to refer to the file by name, so that
    # SCons will recreate the file node after we've deleted it; if we used the
    # env.File() we created in the if statement above, SCons would still think
    # it exists and not rerun the test.
    test_out = env.CommandOutput(test_out_name, test_program)

    # Running the test requires the test and its libs copied to the tests dir
    env.Depends(test_out, all_outputs)
    env.ComponentTestOutput('run_' + prog_name, test_out)

    # Add target properties
    env.SetTargetProperty(prog_name, RUN_TARGET='run_' + prog_name)

def ComponentTestProgram(self, prog_name, *args, **kwargs):
  """Pseudo-builder for test program to handle platform-dependent type.

  Args:
    self: Environment in which we were called.
    prog_name: Test program name.
    args: Positional arguments.
    kwargs: Keyword arguments.

  Returns:
    Output node list from env.Program().

  TODO(rspangler): Should have some sort of support for S/M/L categorization
  """
  # Clone and modify environment
  env = _ComponentPlatformSetup(self, 'ComponentTestProgram', **kwargs)

  env['PROGRAM_BASENAME'] = prog_name
  env['PROGRAM_NAME'] = '$PROGPREFIX$PROGRAM_BASENAME$PROGSUFFIX'

  # Call env.Program()
  out_nodes = env.Program(prog_name, *args, **kwargs)

  # Add dependencies on includes
  env.Depends(out_nodes, env['INCLUDES'])

  # Publish output
  env.Publish(prog_name, 'run', out_nodes[0])
  env.Publish(prog_name, 'debug', out_nodes[1:])

  # Add an alias to build the program to the right groups
  a = env.Alias(prog_name, out_nodes)
  for group in env['COMPONENT_TEST_PROGRAM_GROUPS']:
    SCons.Script.Alias(group, a)

  # Store list of components for this program
  env._StoreComponents(prog_name)

  # Let component_targets know this target is available in the current mode
  env.SetTargetProperty(prog_name, TARGET_PATH=out_nodes[0])

  # Set up deferred call to replicate resources and run test
  env.Defer(ComponentTestProgramDeferred)

  # Return the output node
  return out_nodes

#------------------------------------------------------------------------------


def ComponentProgramDeferred(env):
  """Deferred build steps for program.

  Args:
    env: Environment from ComponentProgram().

  Sets up the aliases to compile the program.
  """
  prog_name = env['PROGRAM_BASENAME']

  # Install program and resources
  all_outputs = []
  components = _RetrieveComponents(prog_name)
  for resource, dest_dir in env.get('COMPONENT_PROGRAM_RESOURCES').items():
    all_outputs += env.ReplicatePublished(dest_dir, components, resource)

  # Add installed program and resources to the alias
  env.Alias(prog_name, all_outputs)


def ComponentProgram(self, prog_name, *args, **kwargs):
  """Pseudo-builder for program to handle platform-dependent type.

  Args:
    self: Environment in which we were called.
    prog_name: Test program name.
    args: Positional arguments.
    kwargs: Keyword arguments.

  Returns:
    Output node list from env.Program().
  """
  # Clone and modify environment
  env = _ComponentPlatformSetup(self, 'ComponentProgram', **kwargs)

  env['PROGRAM_BASENAME'] = prog_name

  # Call env.Program()
  out_nodes = env.Program(prog_name, *args, **kwargs)

  # Add dependencies on includes
  env.Depends(out_nodes, env['INCLUDES'])

  # Publish output
  env.Publish(prog_name, 'run', out_nodes[0])
  env.Publish(prog_name, 'debug', out_nodes[1:])

  # Add an alias to build the program to the right groups
  a = env.Alias(prog_name, out_nodes)
  for group in env['COMPONENT_PROGRAM_GROUPS']:
    SCons.Script.Alias(group, a)

  # Store list of components for this program
  env._StoreComponents(prog_name)

  # Let component_targets know this target is available in the current mode
  env.SetTargetProperty(prog_name)

  # Set up deferred call to replicate resources
  env.Defer(ComponentProgramDeferred)

  # Return the output nodes
  return out_nodes

#------------------------------------------------------------------------------


def ComponentTestOutput(self, test_name, nodes):
  """Pseudo-builder for test output.

  Args:
    self: Environment in which we were called.
    test_name: Test name.
    nodes: List of files/Nodes output by the test.

  Returns:
    Passthrough return code from env.Alias().
  """

  # Add an alias for the test output
  a = self.Alias(test_name, nodes)

  groups = self.get('COMPONENT_TEST_OUTPUT_GROUPS')
  if not groups:
    # Output group not explicitly specified, so automatically add to groups
    if self.get('COMPONENT_TEST_ENABLED'):
      # Enabled tests go in all tests, and their size category
      groups = ['run_all_tests']
      if self.get('COMPONENT_TEST_SIZE'):
        groups.append(self.subst('run_${COMPONENT_TEST_SIZE}_tests'))
    else:
      # Disabled tests only go in their group
      groups = ['run_disabled_tests']

  for group in groups:
    SCons.Script.Alias(group, a)

  # Let component_targets know this target is available in the current mode
  self.SetTargetProperty(test_name, TARGET_PATH=nodes[0])

  # Return the output node
  return a

#------------------------------------------------------------------------------


def generate(env):
  # NOTE: SCons requires the use of this name, which fails gpylint.
  """SCons entry point for this tool."""

  env.Replace(
      LIB_DIR='$TARGET_ROOT/lib',
      # TODO(rspangler): Remove legacy COMPONENT_LIBRARY_DIR, once all users
      # have transitioned to LIB_DIR
      COMPONENT_LIBRARY_DIR='$LIB_DIR',
      STAGING_DIR='$TARGET_ROOT/staging',
      TESTS_DIR='$TARGET_ROOT/tests',
      TEST_OUTPUT_DIR='$TARGET_ROOT/test_output',
      # Default command line for a test is just the name of the file.
      # TODO(rspangler): Why doesn't the following work:
      # COMPONENT_TEST_CMDLINE='${SOURCE.abspath}',
      # (it generates a SCons error)
      COMPONENT_TEST_CMDLINE='${PROGRAM_NAME}',
      # Component tests are runnable by default.
      COMPONENT_TEST_RUNNABLE=True,
      # Default test size is large
      COMPONENT_TEST_SIZE='large',
      # Default timeouts for component tests
      COMPONENT_TEST_TIMEOUT={'large': 900, 'medium': 450, 'small': 180},
      # Tests are enabled by default
      COMPONENT_TEST_ENABLED=True,
      # Static linking is a sensible default
      COMPONENT_STATIC=True,
      # Don't publish libraries to the staging dir by themselves by default.
      COMPONENT_LIBRARY_PUBLISH=False,
  )
  env.Append(
      LIBPATH=['$LIB_DIR'],
      RPATH=['$LIB_DIR'],

      # Default alias groups for component builders
      COMPONENT_PACKAGE_GROUPS=['all_packages'],
      COMPONENT_LIBRARY_GROUPS=['all_libraries'],
      COMPONENT_PROGRAM_GROUPS=['all_programs'],
      COMPONENT_TEST_PROGRAM_GROUPS=['all_test_programs'],

      # Additional components whose resources should be copied into program
      # directories, in addition to those from LIBS and the program itself.
      LIBS=[],
      COMPONENTS=[],

      # Dicts of what resources should go in each destination directory for
      # programs and test programs.
      COMPONENT_PACKAGE_RESOURCES={
          'run': '$PACKAGE_DIR',
          'debug': '$PACKAGE_DIR',
      },
      COMPONENT_PROGRAM_RESOURCES={
          'run': '$STAGING_DIR',
          'debug': '$STAGING_DIR',
      },
      COMPONENT_TEST_RESOURCES={
          'run': '$TESTS_DIR',
          'debug': '$TESTS_DIR',
          'test_input': '$TESTS_DIR',
      },
  )

  # Add command line option for retest
  SCons.Script.AddOption(
      '--retest',
      dest='component_test_retest',
      action='store_true',
      help='force all tests to rerun')
  SCons.Script.Help('  --retest                    '
                    'Rerun specified tests, ignoring cached results.\n')

  # Defer per-environment initialization, but do before building SConscripts
  env.Defer(_InitializeComponentBuilders)
  env.Defer('BuildEnvironmentSConscripts', after=_InitializeComponentBuilders)

  # Add our pseudo-builder methods
  env.AddMethod(_StoreComponents)
  env.AddMethod(ComponentPackage)
  env.AddMethod(ComponentObject)
  env.AddMethod(ComponentLibrary)
  env.AddMethod(ComponentProgram)
  env.AddMethod(ComponentTestProgram)
  env.AddMethod(ComponentTestOutput)

  # Add our target groups
  AddTargetGroup('all_libraries', 'libraries can be built')
  AddTargetGroup('all_programs', 'programs can be built')
  AddTargetGroup('all_test_programs', 'tests can be built')
  AddTargetGroup('all_packages', 'packages can be built')
  AddTargetGroup('run_all_tests', 'tests can be run')
  AddTargetGroup('run_disabled_tests', 'tests are disabled')
  AddTargetGroup('run_small_tests', 'small tests can be run')
  AddTargetGroup('run_medium_tests', 'medium tests can be run')
  AddTargetGroup('run_large_tests', 'large tests can be run')