# Copyright (c) 2014 The Chromium Embedded Framework Authors. All rights
# reserved. Use of this source code is governed by a BSD-style license that
# can be found in the LICENSE file.

from __future__ import absolute_import
from __future__ import print_function
from io import open
from optparse import Option, OptionParser, OptionValueError
import os
import re
import sys
from exec_util import exec_cmd
from file_util import copy_file, move_file, read_file, remove_file
import git_util as git

backup_ext = '.cefbak'


def msg(message):
  """ Output a message. """
  sys.stdout.write('--> ' + message + "\n")


def linebreak():
  """ Output a line break. """
  sys.stdout.write('-' * 80 + "\n")


def warn(message):
  """ Output a warning. """
  linebreak()
  sys.stdout.write('!!!! WARNING: ' + message + "\n")
  linebreak()


def extract_paths(file):
  """ Extract the list of modified paths from the patch file. """
  paths = []
  with open(file, 'r', encoding='utf-8') as fp:
    for line in fp:
      if line[:4] != '+++ ':
        continue
      match = re.match('^([^\t]+)', line[4:])
      if not match:
        continue
      paths.append(match.group(1).strip())
  return paths


# Cannot be loaded as a module.
if __name__ != "__main__":
  sys.stderr.write('This file cannot be loaded as a module!')
  sys.exit()

# Parse command-line options.
disc = """
This utility updates existing patch files.
"""


# Support options with multiple arguments.
class MultipleOption(Option):
  ACTIONS = Option.ACTIONS + ("extend",)
  STORE_ACTIONS = Option.STORE_ACTIONS + ("extend",)
  TYPED_ACTIONS = Option.TYPED_ACTIONS + ("extend",)
  ALWAYS_TYPED_ACTIONS = Option.ALWAYS_TYPED_ACTIONS + ("extend",)

  def take_action(self, action, dest, opt, value, values, parser):
    if action == "extend":
      values.ensure_value(dest, []).append(value)
    else:
      Option.take_action(self, action, dest, opt, value, values, parser)


parser = OptionParser(option_class=MultipleOption, description=disc)
parser.add_option(
    '--resave',
    action='store_true',
    dest='resave',
    default=False,
    help='resave existing patch files to pick up manual changes')
parser.add_option(
    '--reapply',
    action='store_true',
    dest='reapply',
    default=False,
    help='reapply the patch without first reverting changes')
parser.add_option(
    '--revert',
    action='store_true',
    dest='revert',
    default=False,
    help='revert all changes from existing patch files')
parser.add_option(
    '--backup',
    action='store_true',
    dest='backup',
    default=False,
    help='backup patched files. Used in combination with --revert.')
parser.add_option(
    '--restore',
    action='store_true',
    dest='restore',
    default=False,
    help='restore backup of patched files that have not changed. If a backup has ' +\
         'changed the patch file will be resaved. Used in combination with --reapply.')
parser.add_option(
    '--patch',
    action='extend',
    dest='patch',
    type='string',
    default=[],
    help='optional patch name to process (multiples allowed)')
parser.add_option(
    '--add',
    action='extend',
    dest='add',
    type='string',
    default=[],
    help='optional relative file paths to add (multiples allowed). Used in ' +\
         'combination with --resave and a single --patch value.')
(options, args) = parser.parse_args()

if options.resave and options.revert:
  print('Invalid combination of options.')
  parser.print_help(sys.stderr)
  sys.exit()

if len(options.add) > 0 and (len(options.patch) != 1 or not options.resave):
  print('--add can only be used with --resave and a single --patch value.')
  parser.print_help(sys.stderr)
  sys.exit()

# The CEF root directory is the parent directory of _this_ script.
cef_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
src_dir = os.path.abspath(os.path.join(cef_dir, os.pardir))

# Determine the type of Chromium checkout.
if not git.is_checkout(src_dir):
  raise Exception('Not a valid checkout: %s' % src_dir)

patch_dir = os.path.join(cef_dir, 'patch')
patch_cfg = os.path.join(patch_dir, 'patch.cfg')
if not os.path.isfile(patch_cfg):
  raise Exception('File does not exist: %s' % patch_cfg)

# Read the patch configuration file.
msg('Reading patch config %s' % patch_cfg)
scope = {}
exec (compile(open(patch_cfg, "rb").read(), patch_cfg, 'exec'), scope)
patches = scope["patches"]

failed_patches = {}

# Read each individual patch file.
patches_dir = os.path.join(patch_dir, 'patches')
for patch in patches:
  # If specific patch names are specified only process those patches.
  if options.patch and not patch['name'] in options.patch:
    continue

  sys.stdout.write('\n')
  patch_file = os.path.join(patches_dir, patch['name'] + '.patch')

  if os.path.isfile(patch_file):
    msg('Reading patch file %s' % patch_file)
    if 'path' in patch:
      patch_root_abs = os.path.abspath(os.path.join(src_dir, patch['path']))
    else:
      patch_root_abs = src_dir

    # Retrieve the list of paths modified by the patch file.
    patch_paths = extract_paths(patch_file)

    # List of paths added by the patch file.
    added_paths = []

    # True if any backed up files have changed.
    has_backup_changes = False

    if not options.resave:
      if not options.reapply:
        # Revert any changes to existing files in the patch.
        for patch_path in patch_paths:
          patch_path_abs = os.path.abspath(os.path.join(patch_root_abs, \
                                                        patch_path))
          if os.path.exists(patch_path_abs):
            if options.backup:
              backup_path_abs = patch_path_abs + backup_ext
              if not os.path.exists(backup_path_abs):
                msg('Creating backup of %s' % patch_path_abs)
                copy_file(patch_path_abs, backup_path_abs)
              else:
                msg('Skipping backup of %s' % patch_path_abs)

            msg('Reverting changes to %s' % patch_path_abs)
            cmd = 'git checkout -- %s' % (patch_path_abs)
            result = exec_cmd(cmd, patch_root_abs)
            if result['err'] != '':
              msg('Failed to revert file: %s' % result['err'])
              msg('Deleting file %s' % patch_path_abs)
              os.remove(patch_path_abs)
              added_paths.append(patch_path_abs)
            if result['out'] != '':
              sys.stdout.write(result['out'])
          else:
            msg('Skipping non-existing file %s' % patch_path_abs)
            added_paths.append(patch_path_abs)

      if not options.revert:
        # Chromium files are occasionally (incorrectly) checked in with Windows
        # line endings. This will cause the patch tool to fail when attempting
        # to patch those files on Posix systems. Convert any such files to Posix
        # line endings before applying the patch.
        converted_files = []
        for patch_path in patch_paths:
          patch_path_abs = os.path.abspath(os.path.join(patch_root_abs, \
                                                        patch_path))
          if os.path.exists(patch_path_abs):
            with open(patch_path_abs, 'r', encoding='utf-8') as fp:
              contents = fp.read()
            if "\r\n" in contents:
              msg('Converting to Posix line endings for %s' % patch_path_abs)
              converted_files.append(patch_path_abs)
              contents = contents.replace("\r\n", "\n")
              with open(patch_path_abs, 'wb') as fp:
                fp.write(contents)

        # Apply the patch file.
        msg('Applying patch to %s' % patch_root_abs)
        patch_string = open(patch_file, 'rb').read()
        result = exec_cmd('patch -p0', patch_root_abs, patch_string)

        if len(converted_files) > 0:
          # Restore Windows line endings in converted files so that the diff is
          # correct if/when the patch file is re-saved.
          for patch_path_abs in converted_files:
            with open(patch_path_abs, 'rb') as fp:
              contents = fp.read()
            msg('Converting to Windows line endings for %s' % patch_path_abs)
            contents = contents.replace("\n", "\r\n")
            with open(patch_path_abs, 'wb') as fp:
              fp.write(contents)

        if result['err'] != '':
          raise Exception('Failed to apply patch file: %s' % result['err'])
        sys.stdout.write(result['out'])
        if result['out'].find('FAILED') != -1:
          failed_lines = []
          for line in result['out'].split('\n'):
            if line.find('FAILED') != -1:
              failed_lines.append(line.strip())
          warn('Failed to apply %s, fix manually and run with --resave' % \
               patch['name'])
          failed_patches[patch['name']] = failed_lines
          continue

        if options.restore:
          # Restore from backup if a backup exists.
          for patch_path in patch_paths:
            patch_path_abs = os.path.abspath(os.path.join(patch_root_abs, \
                                                          patch_path))
            backup_path_abs = patch_path_abs + backup_ext
            if os.path.exists(backup_path_abs):
              if read_file(patch_path_abs) == read_file(backup_path_abs):
                msg('Restoring backup of %s' % patch_path_abs)
                remove_file(patch_path_abs)
                move_file(backup_path_abs, patch_path_abs)
              else:
                msg('Discarding backup of %s' % patch_path_abs)
                remove_file(backup_path_abs)
                has_backup_changes = True
            else:
              msg('No backup of %s' % patch_path_abs)

    if (not options.revert and not options.reapply) or has_backup_changes:
      if len(options.add) > 0:
        # Add additional requested files to the patch.
        for patch_path in options.add:
          patch_path_abs = os.path.abspath(os.path.join(patch_root_abs, \
                                                        patch_path))
          if os.path.exists(patch_path_abs):
            msg('Adding file %s' % patch_path_abs)
            patch_paths.append(patch_path)
          else:
            msg('Skipping non-existing file %s' % patch_path_abs)

      msg('Saving changes to %s' % patch_file)
      if added_paths:
        # Inform git of the added paths so they appear in the patch file.
        cmd = 'git add -N %s' % ' '.join(added_paths)
        result = exec_cmd(cmd, patch_root_abs)
        if result['err'] != '' and result['err'].find('warning:') != 0:
          raise Exception('Failed to add paths: %s' % result['err'])

      # Re-create the patch file.
      patch_paths_str = ' '.join(patch_paths)
      cmd = 'git diff --no-prefix --relative %s' % patch_paths_str
      result = exec_cmd(cmd, patch_root_abs)
      if result['err'] != '' and result['err'].find('warning:') != 0:
        raise Exception('Failed to create patch file: %s' % result['err'])

      if "\r\n" in result['out']:
        # Patch files should always be saved with Posix line endings.
        # This will avoid problems when attempting to re-apply the patch
        # file on Posix systems.
        msg('Converting to Posix line endings for %s' % patch_file)
        result['out'] = result['out'].replace("\r\n", "\n")

      f = open(patch_file, 'w', encoding='utf-8')
      f.write(result['out'])
      f.close()
  else:
    raise Exception('Patch file does not exist: %s' % patch_file)

if len(failed_patches) > 0:
  sys.stdout.write("\n")
  linebreak()
  sys.stdout.write("!!!! FAILED PATCHES, fix manually and run with --resave\n")
  for name in sorted(failed_patches.keys()):
    sys.stdout.write("%s:\n" % name)
    for line in failed_patches[name]:
      if sys.platform == 'win32' and line.find('.rej') > 0:
        # Convert paths to use Windows-style separator.
        line = line.replace('/', '\\')
      sys.stdout.write("  %s\n" % line)
  linebreak()
  sys.exit(1)