import ctypes
import getpass
import os
import re
import shutil
import subprocess
import sys
import threading
import thread
import urllib

BLACK   = 0x00
BLUE    = 0x01 # text color contains blue.
GREEN   = 0x02 # text color contains green.
CYAN    = 0x03
RED     = 0x04
PURPLE  = 0x05
BROWN   = 0x06
LGREY   = 0x07
GREY    = 0x08
LBLUE   = 0x09
LGREEN  = 0x0A
LCYAN   = 0x0B
PINK    = 0x0C
LPURPLE = 0x0D
YELLOW  = 0x0E
WHITE   = 0x0F

def BKG(color):
  return color << 4

# See http://msdn.microsoft.com/library/default.asp?url=/library/en-us/winprog/winprog/windows_api_reference.asp
# for information on Windows APIs.
STD_OUT_HANDLE = ctypes.windll.kernel32.GetStdHandle(-11)
STD_ERR_HANDLE = ctypes.windll.kernel32.GetStdHandle(-12)

LKGR = 'http://build.chromium.org/buildbot/continuous/LATEST/REVISION'

# Functions that can be called to solve known problems. Arguments are:
#   root_path: The root path for the step that was being executed when the error
#              occured.
#   match:     The regular expression match object that detected the problem.
# The return value of each function should be a boolean or None:
#   True:      The problem should be solved, try again.
#   False:     The problem could not be solved, abort.
#   None:      The problem can safely be ignored.

def Help():
  print """
Build.py - Automatically download and build Chromium from source.

Syntax:
    build.py [Options]

Options:
  --depot_tools=path
    Specify the path to the Chromium depot_tools folder.
    Default: attempt to find automagically.
  --vs=path
    Specify the path to Visual Studio. 
    Default: attempt to find automagically.
  --chromium=path
    Specify the path to Chromium source.
    Default: attempt to find automagically.
  --build="Debug"|"Release"
    Specify the build type.
    Default: "Debug".
  --rebuild[="Debug"|"Release"]
    Specify the build type and force a clean rebuild.
    Default is incremental "Debug" build.
  --log=path
    Specific the path to log file for the build process (default: 'build.log')
  --sync[="True"|"False"]
    Sync the source before building.
    Default: "True".
  --lkgr[=url]
    Sync to the Last Known Good Revision rather than the last revision.
    The LKGR is retreived from the default URL, unless one is specified in the 
    argument.
    Default url: http://build.chromium.org/buildbot/continuous/LATEST/REVISION.
  --accept="postpone"|"base"|"mine-conflict"|"theirs-conflict"|"mine-full"|
           "theirs-full"|"edit"|"launch"
    Passed as an argument to svn to automatically resolve conflicts.
    See "svn help update" for details.
  --user-data-dir=path
    Delete the specified "user-data-dir".

Example:
  build.py --chromium="C:\trunk\src" --rebuild --lkgr --accept="mine-full"
    Syncs to the Last Known Good Revision in C:\trunk\src, keeping all local
    changes and then rebuilds a Debug build.
"""

def PrintHeader(header):
  ctypes.windll.kernel32.SetConsoleTextAttribute(STD_OUT_HANDLE, WHITE)
  print ('________ %s ' % header).ljust(80, '_')
  ctypes.windll.kernel32.SetConsoleTextAttribute(STD_OUT_HANDLE, LGREY)

def _DeleteUnversionedDirectory(root_path, match):
  path = match.group(1)
  PrintHeader('Deleting un-versioned directory')
  return _DisplayAndReturnResultOfSolution(DelTree(path) == 0)

def _DeleteDeprecatedDirectory(root_path, match):
  path = match.group(1)
  PrintHeader('Deleting deprecated directory')
  return _DisplayAndReturnResultOfSolution(DelTree(path) == 0)

def _DeleteNonWorkingCopyDirectory(root_path, match):
  path = os.path.join(root_path, match.group(1))
  PrintHeader('Deleting non-working copy directory')
  return _DisplayAndReturnResultOfSolution(DelTree(path) == 0)

def _DeleteStaleDirectory(root_path, match):
  path = os.path.join(root_path, match.group(1))
  PrintHeader('Deleting stale directory')
  return _DisplayAndReturnResultOfSolution(DelTree(path) == 0)

def _IgnoreProblem(root_path, match):
  return _DisplayAndReturnResultOfSolution(None)
  
def _RetryStep(root_path, match):
  PrintHeader('Restarting to try again')
  return True

def _SVNCleanup(root_path, match):
  PrintHeader('GCLIENT: cleanup')
  exit_code = ExecuteStepWithSolutions(
      'cleanup', globals.gclient_bat, ['cleanup'], globals.chromium_src)
  return _DisplayAndReturnResultOfSolution(exit_code == 0)

def _RunHooks(root_path, match):
  PrintHeader('GCLIENT: runhooks')
  exit_code = ExecuteStepWithSolutions(
      'runhooks', globals.gclient_bat, ['runhooks', '--force'], 
      globals.chromium_src)
  return _DisplayAndReturnResultOfSolution(exit_code == 0)

def _CleanSolution(root_path=None, match=None):
  PrintHeader('VS: Clean solution')
  exit_code = ExecuteStepWithSolutions('clean', globals.devenv_com, [
      globals.chromium_src + '\\chrome\\chrome.sln', '/Clean'], 
      globals.chromium_src)
  assert exit_code == 0, ('Error while cleaning up solution')
  
  PrintHeader('Delete output directory' % globals.build_type)
  assert DelTree(globals.chromium_src + '\\chrome\\' + globals.build_type
      ) == 0, ('Error while deleting %s output directory' % globals.build_type)

# This is a helper function that outputs a description of the result of a
# solution function and passes it on.
def _DisplayAndReturnResultOfSolution(fixed):
  if fixed == False:
    PrintHeader('Problem could not be solved')
  elif fixed == None:
    PrintHeader('Problem ignored')
  else:
    PrintHeader('Problem should be fixed')
  return fixed

# List of regular expressions that can be used to detect known problems in the
# output of various steps of the sync/build process and the functions that need
# to be executed in order to try and solve these problems or True to output the
# match (used for creating a list of non-solvable problems to be output after a
# command failed.)
PROBLEMS_AND_SOLUTIONS = {
  'cleanup-stdout': {},
  'cleanup-stderr': {
    'svn: \'(.*)\' is not a working copy directory': 
        _DeleteNonWorkingCopyDirectory },
  'sync-stdout': {
  },
  'sync-stderr': {
    'svn\\: Working copy \'.*\' locked': _SVNCleanup,
    'svn\\: Failed to add directory \'(.*)\'\\: an unversioned directory of '
        'the same name already exists': _DeleteUnversionedDirectory,
    'svn\\: REPORT of \'.*\'\\: Could not read response body\\: connection '
        'was closed by server \\(.*\\)': _RetryStep,
    'svn\\: REPORT of \'.*\'\\: Could not read response body\\: An existing '
        'connection was forcibly closed by the remote host\\.': _RetryStep,
    'svn\\: REPORT of \'.*\': 200 OK \\(.*\\)': _RetryStep,
    '(.*)\\:  \\(Not a versioned resource\\)': _DeleteUnversionedDirectory },
  'build-stdout': {
    'LINK \\: fatal error LNK\\d+\\: cannot open input file \'.*\'': None,
    '.* \\: fatal error C1083\\: Cannot open include file\\: \'(third_party/'
        'WebKit/WebKit).*\'\\: No such file or directory': 
        _DeleteDeprecatedDirectory,
    '.* \\: fatal error C\\d+\\: Cannot open include file\\: \'.*\': No such '
        'file or directory': None,
    '.* \\: error C\\d+\\: .*': _CleanSolution,
    '(.*\\webkit)\\/glue.* \\: error C2039\\: \'.*\' \\: is not a member of '
        '\'WebKit\'': _DeleteStaleDirectory,
    'Project file \'(.*)\' could not be loaded.': _RunHooks
  }
}

class globals:
  depot_tools = None
  gclient_bat = None
  devenv_com = None
  chromium_src = None
  build_type = 'Debug'
  log_file = 'build.log'
  sync = True
  lkgr = False
  rebuild = False
  accept = None
  user_data_dir = None
  output_lock = None

# Environment parsing functions
def ParseEnvironment(string, replaceables={}):
  replacees = re.compile('%.*?%')
  for match in replacees.finditer(string):
    name = match.group()[1:-1]
    if name in replaceables:
      value = replaceables[name]
    else:
      value = os.environ.get(name)
      if value is None and name.lower() in ENVIRONMENT_REPLACEMENTS:
        value = ENVIRONMENT_REPLACEMENTS[name.lower()](name)
    if value is not None:
      string = string.replace(match.group(), value)
  return string

def _EnvironmentAppdata(name):
  return Parse('%USERPROFILE%\\Application Data')

def _EnvironmentLocalappdata(name):
  return Parse('%USERPROFILE%\\Local Settings\\Application Data')

def _EnvironmentUsername(name):
  return getpass.getuser()

def _EnvironmentProgramfiles_x86(name):
  return os.environ.get('ProgramFiles')

ENVIRONMENT_REPLACEMENTS = {
  'appdata': _EnvironmentAppdata,
  'localappdata': _EnvironmentLocalappdata,
  'username': _EnvironmentUsername,
  'programfiles(x86)': _EnvironmentProgramfiles_x86
}

# Function to find a certain something in a list of potential paths: returns
# the first file or directory that exists in a given array of paths, after 
# environment variables in each path have been expanded.
def FindPath(what_are_we_looking_for, potential_paths):
  for potential_path in potential_paths:
    path = ParseEnvironment(potential_path)
    if (os.path.exists(path)):
      break
  else:
    raise AssertionError('Cannot find %s' % (what_are_we_looking_for,))
  return path

def LongestPatternFirstSort(x, y):
  # This is what we should return
  #   x < y  ==> return < 0
  #   x == y ==> return   0
  #   x > y  ==> return > 0
  # ...where x < y means x comes before y
  # We want the longest numbers first, if we subtract the lengths of x and y
  # (len(x)-len(y), then we get this:
  #   x < y   (len(x)>len(y))  ==> len(x)-len(y) > 0
  #   x == y  (len(x)==len(y)) ==> len(x)-len(y) == 0
  #   x > y   (len(x)<len(y))  ==> len(x)-len(y) < 0
  # So if we take the negative value of that, we can return it:
  return len(y.pattern)-len(x.pattern)

# Run a certain command in a certain folder, complete with piping of output and
# detection of problems using the regular expression patterns in the list
# "PROBLEMS_AND_SOLUTIONS". It executes the associated function to try to solve
# the problem.
def ExecuteStepWithSolutions(step, binary_path, arguments, root_path):
  stdout_patterns = []
  stderr_patterns = []
  if (step + '-stdout') in PROBLEMS_AND_SOLUTIONS:
    for pattern, solution in PROBLEMS_AND_SOLUTIONS[step + '-stdout'].items():
      stdout_patterns.append(re.compile(pattern))
    stdout_patterns.sort(LongestPatternFirstSort)
  if (step + '-stderr') in PROBLEMS_AND_SOLUTIONS:
    for pattern, solution in PROBLEMS_AND_SOLUTIONS[step + '-stderr'].items():
      stderr_patterns.append(re.compile(pattern))
    stderr_patterns.sort(LongestPatternFirstSort)
  while 1:
    exit_code, stdout_pm_list, stderr_pm_list = RunProcess(step, binary_path, 
        arguments, stdout_patterns, stderr_patterns)
#    print 'stdout matches: %d' % len(stdout_pm_list)
#    print 'stderr matches: %d' % len(stderr_pm_list)
    if not stdout_pm_list and not stderr_pm_list:
      # No more fixes available: return the result
      break
    # Are there errors found in stdout that can potentially be fixed?
    solution = None
    pipe = None
    unsolveable_known_problems = []
    if stdout_pm_list:
      # Yes, try to fix those:
      for (pattern, match) in stdout_pm_list:
        solution = PROBLEMS_AND_SOLUTIONS[step + '-stdout'][pattern.pattern]
        if solution == None:
          # This problem is known but does not have a solution; add it to a list
          # so it can be printed later.
          unsolveable_known_problems.append(match)
        else:
          # This problem is known and we can try to fix it using the function
          # we got from the PROBLEMS_AND_SOLUTIONS dict.
          # Since we'll try to fix this issue, remove it from the list of issues
          # that we'll look for so we do not try to fix it again:
          while stdout_patterns.count(pattern):
            stdout_patterns.remove(pattern)
          pipe = 'stdout'
          break
    else:
      # No, there must have been some in stderr, try to fix those:
      for (pattern, match) in stderr_pm_list:
        solution = PROBLEMS_AND_SOLUTIONS[step + '-stderr'][pattern.pattern]
        if solution == None:
          # This problem is known but does not have a solution; add it to a list
          # so it can be printed later.
          unsolveable_known_problems.append(match)
        else:
          # This problem is known and we can try to fix it using the function
          # we got from the PROBLEMS_AND_SOLUTIONS dict.
          # Since we'll try to fix this issue, remove it from the list of issues
          # that we'll look for so we do not try to fix it again:
          while stderr_patterns.count(pattern):
            stderr_patterns.remove(pattern)
          pipe = 'stderr'
          break
    if solution == None:
      if len(unsolveable_known_problems) == 1:
        print 'The following problem was detected:'
      else:
        print 'The following problems were detected:'
      for match in unsolveable_known_problems:
        print '- %s' % match.group(0)
      # Stop this loop and return whatever error code the command returned:
      break
    else:
      # Apply the solution and check the result:
      fixed = solution(root_path, match)
      if fixed == False:
        return -1 # Problem could not be fixed
      elif fixed == None:
        return 0 # Problem should be ignored.
  return exit_code

def RunProcess(line_header, binary_path, arguments, stdout_patterns = [], 
    stderr_patterns = []):
  assert os.path.isfile(binary_path), (
      'Cannot run non-existing file: "%s"' % binary_path,)
  command = '"%s" %s' % (binary_path, ' '.join(arguments))
  ctypes.windll.kernel32.SetConsoleTextAttribute(STD_OUT_HANDLE, LGREY)
  print 'Command: ' + command
  child_process = subprocess.Popen(command,
      stdout = subprocess.PIPE, stderr = subprocess.PIPE)
  # Start a thread that pipes stdout and detects any error messages:
  stdout_pm_list = []
  stdout_thread = PipeAndMatchPattern(child_process.stdout, sys.stdout, 
      stdout_patterns, stdout_pm_list, GREEN)

  # Start a thread that reads stderr and detects any error messages:
  # (This is not piped directly but output later to prevent stdout and stderr 
  # from interfering with each other)
  stderr_pm_list = []
  stderr_list = []
  stderr_thread = PipeAndMatchPattern(child_process.stderr, sys.stderr, 
      stderr_patterns, stderr_pm_list, RED)
  # Wait for stdout and stderr threads to finish:
  stdout_thread.join()
  stderr_thread.join()
  exit_code = child_process.wait()
  return (exit_code, stdout_pm_list, stderr_pm_list)

def DelTree(path):
  ctypes.windll.kernel32.SetConsoleTextAttribute(STD_OUT_HANDLE, GREEN)
  print 'Deleting "%s"...' % path,
  rv = [0]
  failed = False
  path = re.sub(r'/', r'\\', path) # linux style to windows style
  def onError(func, path, excinfo):
    if not failed:
      ctypes.windll.kernel32.SetConsoleTextAttribute(STD_OUT_HANDLE, RED)
      print 'failed!'
      failed = True
    print 'Couldn\'t remove "%s": "%s"' % (path, excinfo)
    rv[0] = [1]
  shutil.rmtree(path, False, onError)
  if not failed:
    print 'ok.'
  ctypes.windll.kernel32.SetConsoleTextAttribute(STD_OUT_HANDLE, LGREY)
  return rv[0]

def PipeAndMatchPattern(*args):
  pipe_thread = threading.Thread(target=_PipeAndMatchPatternThread, args=args)
  pipe_thread.start()
  return pipe_thread

def _PipeAndMatchPatternThread(pipe_in, pipe_out, 
    patterns = [], pattern_and_match_list=None, color=None):
#  print 'pipe_in: %s %s' % (type(pipe_in), pipe_in)
#  print 'pipe_out: %s %s' % (type(pipe_out), pipe_out)
#  print 'patterns: %s %s' % (type(patterns), patterns)
#  for pattern in patterns:
#    print 'pattern: %s' % pattern.pattern
#  print 'pattern_and_match_list: %s %s' % (type(pattern_and_match_list), pattern_and_match_list)
  in_byte = pipe_in.read(1)
  in_line = ''
  while in_byte:
    if in_byte != '\r':
      if in_line == '':
        # We will be outputing a line, so we need to lock output to prevent
        # stdout/stderr output to mingle.
        globals.output_lock.acquire()
      if color:
        ctypes.windll.kernel32.SetConsoleTextAttribute(STD_OUT_HANDLE, color)
      pipe_out.write(in_byte)
      pipe_out.flush()
      if color:
        ctypes.windll.kernel32.SetConsoleTextAttribute(STD_OUT_HANDLE, LGREY)
      in_line += in_byte
    if in_byte == '\n':
      if patterns:
        for pattern in patterns:
          match = pattern.search(in_line[:-1])
          if match:
#            print 'match: %s' % (match,)
            pattern_and_match_list.append((pattern, match))
      # We have output a line, so we can unlock output so that stdout/stderr
      # can take over from another and output lines.
      globals.output_lock.release()
      in_line = ''
    in_byte = pipe_in.read(1)
  # If the pipe did not end in a newline, output the last bytes and add one:
  if in_line:
    pipe_out.write('\n')
    pipe_out.flush()

# Main code
def Main(my_name, *args):
  globals.output_lock = thread.allocate_lock()
  for arg in args:
    if arg in ['-h', '-?', '/?', '/h', '--help']:
      Help()
      return 0
    if arg.startswith('--'):
      split_at = arg.find('=')
      if split_at == -1:
        arg_name = arg[2:].lower()
        arg_value = ''
      else:
        arg_name = arg[2:split_at].lower()
        arg_value = arg[split_at + 1:]
      if arg_name == 'depot_tools':
        globals.depot_tools = arg_value
      elif arg_name == 'vs':
        globals.devenv_com = FindPath('devenv.com', [
            arg_value + '\\Common7\\IDE\\devenv.com'])
      elif arg_name == 'chromium':
        chrome_sln_abs = os.path.abspath(FindPath('chromium source', [
            arg_value + '\\chrome\\chrome.sln']))
        globals.chromium_src = chrome_sln_abs[:
          chrome_sln_abs.rfind('\\', 0, chrome_sln_abs.rfind('\\'))]
      elif arg_name == 'build':
        globals.build_type = arg_value.capitalize()
        assert globals.build_type in ('Debug', 'Release'), (
            'Build type must be "Debug" or "Release".')
      elif arg_name == 'rebuild':
        globals.rebuild = True
        if arg_value:
          globals.build_type = arg_value.capitalize()
          assert globals.build_type in ('Debug', 'Release'), (
              'Build type must be "Debug" or "Release".')
      elif arg_name == 'log':
        globals.log_file = arg_value
      elif arg_name == 'sync':
        assert not arg_value or arg_value.lower() in ['true', 'false'], (
          'Sync must be "True" or "False".')
        if arg_value and arg_value.lower() != 'true':
          globals.sync = False
      elif arg_name == 'lkgr':
        if arg_value:
          globals.lkgr = arg_value
        else:
          globals.lkgr = LKGR
      elif arg_name == 'accept':
        accept_values = ['postpone', 'base', 'mine-conflict', 
            'theirs-conflict', 'mine-full', 'theirs-full', 'edit', 'launch']
        assert arg_value and arg_value.lower() in accept_values, (
            'Accept argument must be "%s" or "%s"') % (
              '", "'.join(accept_values[:-1]), accept_values[-1])
        globals.accept = arg_value.lower()
      elif arg_name == 'user-data-dir':
        assert arg_value, 'User-data-dir argument must have a path as value'
        globals.user_data_dir = arg_value
      else:
        print 'Unknown option "%s"' % arg_name
        Help()
        return -1
    else:
      print 'Option "%s" must be preceded by "--"' % arg
      Help()
      return -1

  gclient_paths = ['gclient.bat']
  if globals.depot_tools:
    gclient_paths.append(globals.depot_tools + '\gclient.bat')
  gclient_paths.extend([
    r'depot_tools\gclient.bat',
    r'\depot_tools\gclient.bat',
    r'..\depot_tools\gclient.bat',
    r'..\..\depot_tools\gclient.bat'
  ])
  globals.gclient_bat = FindPath('gclient.bat', gclient_paths)
  if not globals.devenv_com:
    globals.devenv_com = FindPath('devenv.com', [
      r'%ProgramFiles(x86)%\Microsoft Visual Studio 8\Common7\IDE\devenv.com',
      r'%ProgramFiles%\Microsoft Visual Studio 8\Common7\IDE\devenv.com'
    ])
  if not globals.chromium_src:
    chrome_sln_abs = os.path.abspath(FindPath('chrome.sln', [
      r'chrome\chrome.sln',
      r'src\chrome\chrome.sln'
    ]))
    globals.chromium_src = chrome_sln_abs[:
      chrome_sln_abs.rfind('\\', 0, chrome_sln_abs.rfind('\\'))]

  if globals.sync:
    args = ['update']
    if globals.lkgr:
      revision = int(urllib.urlopen(globals.lkgr).read())
      PrintHeader('GCLIENT: update (lkgr %s)' % revision)
      args.append('--revision=src@%d' % revision)
    else:
      PrintHeader('GCLIENT: update')
    if globals.accept:
      args.extend(['--', '--accept=' + globals.accept])
    exit_code = ExecuteStepWithSolutions('sync', globals.gclient_bat, args, 
      globals.chromium_src)
    assert exit_code == 0, ('Error while updating source')

  if globals.rebuild:
  	_CleanSolution()
  
  PrintHeader('VS: Build solution (%s)' % globals.build_type)
  exit_code = ExecuteStepWithSolutions('build', globals.devenv_com, [
      globals.chromium_src + '\\chrome\\chrome.sln', '/Build', 
      '"%s|Win32"' % globals.build_type, '/Log ' + globals.log_file ],
      globals.chromium_src + '\\chrome\\' + globals.build_type)
  assert exit_code == 0, ('Error compiling solution to %s executable' % 
      globals.build_type)

  if globals.user_data_dir and os.path.isdir(globals.user_data_dir):
    PrintHeader('Removing old user-data-dir')
    DelTree(globals.user_data_dir)

if __name__ == '__main__':
  try:
    exit(Main(*sys.argv))
  except AssertionError as error:
    print 'Error: %s' % error
    raw_input('Press ENTER...')
