# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import argparse
import codecs
import plistlib
import os
import re
import subprocess
import sys
import tempfile
import shlex

if sys.version_info.major < 3:
  basestring_compat = basestring
else:
  basestring_compat = str

# Xcode substitutes variables like ${PRODUCT_NAME} or $(PRODUCT_NAME) when
# compiling Info.plist. It also supports supports modifiers like :identifier
# or :rfc1034identifier. SUBSTITUTION_REGEXP_LIST is a list of regular
# expressions matching a variable substitution pattern with an optional
# modifier, while INVALID_CHARACTER_REGEXP matches all characters that are
# not valid in an "identifier" value (used when applying the modifier).
INVALID_CHARACTER_REGEXP = re.compile(r'[_/\s]')
SUBSTITUTION_REGEXP_LIST = (
    re.compile(r'\$\{(?P<id>[^}]*?)(?P<modifier>:[^}]*)?\}'),
    re.compile(r'\$\((?P<id>[^}]*?)(?P<modifier>:[^}]*)?\)'),
)


class SubstitutionError(Exception):
  def __init__(self, key):
    super(SubstitutionError, self).__init__()
    self.key = key

  def __str__(self):
    return "SubstitutionError: {}".format(self.key)


def InterpolateString(value, substitutions):
  """Interpolates variable references into |value| using |substitutions|.

  Inputs:
    value: a string
    substitutions: a mapping of variable names to values

  Returns:
    A new string with all variables references ${VARIABLES} replaced by their
    value in |substitutions|. Raises SubstitutionError if a variable has no
    substitution.
  """

  def repl(match):
    variable = match.group('id')
    if variable not in substitutions:
      raise SubstitutionError(variable)
    # Some values need to be identifier and thus the variables references may
    # contains :modifier attributes to indicate how they should be converted
    # to identifiers ("identifier" replaces all invalid characters by '_' and
    # "rfc1034identifier" replaces them by "-" to make valid URI too).
    modifier = match.group('modifier')
    if modifier == ':identifier':
      return INVALID_CHARACTER_REGEXP.sub('_', substitutions[variable])
    elif modifier == ':rfc1034identifier':
      return INVALID_CHARACTER_REGEXP.sub('-', substitutions[variable])
    else:
      return substitutions[variable]

  for substitution_regexp in SUBSTITUTION_REGEXP_LIST:
    value = substitution_regexp.sub(repl, value)
  return value


def Interpolate(value, substitutions):
  """Interpolates variable references into |value| using |substitutions|.

  Inputs:
    value: a value, can be a dictionary, list, string or other
    substitutions: a mapping of variable names to values

  Returns:
    A new value with all variables references ${VARIABLES} replaced by their
    value in |substitutions|. Raises SubstitutionError if a variable has no
    substitution.
  """
  if isinstance(value, dict):
    return {k: Interpolate(v, substitutions) for k, v in value.items()}
  if isinstance(value, list):
    return [Interpolate(v, substitutions) for v in value]
  if isinstance(value, basestring_compat):
    return InterpolateString(value, substitutions)
  return value


def LoadPList(path):
  """Loads Plist at |path| and returns it as a dictionary."""
  if sys.version_info.major == 2:
    fd, name = tempfile.mkstemp()
    try:
      subprocess.check_call(['plutil', '-convert', 'xml1', '-o', name, path])
      with os.fdopen(fd, 'rb') as f:
        return plistlib.readPlist(f)
    finally:
      os.unlink(name)
  else:
    with open(path, 'rb') as f:
      return plistlib.load(f)


def SavePList(path, format, data):
  """Saves |data| as a Plist to |path| in the specified |format|."""
  # The below does not replace the destination file but update it in place,
  # so if more than one hardlink points to destination all of them will be
  # modified. This is not what is expected, so delete destination file if
  # it does exist.
  if os.path.exists(path):
    os.unlink(path)
  if sys.version_info.major == 2:
    fd, name = tempfile.mkstemp()
    try:
      with os.fdopen(fd, 'wb') as f:
        plistlib.writePlist(data, f)
      subprocess.check_call(['plutil', '-convert', format, '-o', path, name])
    finally:
      os.unlink(name)
  else:
    with open(path, 'wb') as f:
      plist_format = {'binary1': plistlib.FMT_BINARY, 'xml1': plistlib.FMT_XML}
      plistlib.dump(data, f, fmt=plist_format[format])


def MergePList(plist1, plist2):
  """Merges |plist1| with |plist2| recursively.

  Creates a new dictionary representing a Property List (.plist) files by
  merging the two dictionary |plist1| and |plist2| recursively (only for
  dictionary values). List value will be concatenated.

  Args:
    plist1: a dictionary representing a Property List (.plist) file
    plist2: a dictionary representing a Property List (.plist) file

  Returns:
    A new dictionary representing a Property List (.plist) file by merging
    |plist1| with |plist2|. If any value is a dictionary, they are merged
    recursively, otherwise |plist2| value is used. If values are list, they
    are concatenated.
  """
  result = plist1.copy()
  for key, value in plist2.items():
    if isinstance(value, dict):
      old_value = result.get(key)
      if isinstance(old_value, dict):
        value = MergePList(old_value, value)
    if isinstance(value, list):
      value = plist1.get(key, []) + plist2.get(key, [])
    result[key] = value
  return result


class Action(object):
  """Class implementing one action supported by the script."""

  @classmethod
  def Register(cls, subparsers):
    parser = subparsers.add_parser(cls.name, help=cls.help)
    parser.set_defaults(func=cls._Execute)
    cls._Register(parser)


class MergeAction(Action):
  """Class to merge multiple plist files."""

  name = 'merge'
  help = 'merge multiple plist files'

  @staticmethod
  def _Register(parser):
    parser.add_argument('-o',
                        '--output',
                        required=True,
                        help='path to the output plist file')
    parser.add_argument('-f',
                        '--format',
                        required=True,
                        choices=('xml1', 'binary1'),
                        help='format of the plist file to generate')
    parser.add_argument(
        '-x',
        '--xcode-version',
        help='version of Xcode, ignored (can be used to force rebuild)')
    parser.add_argument('path', nargs="+", help='path to plist files to merge')

  @staticmethod
  def _Execute(args):
    data = {}
    for filename in args.path:
      data = MergePList(data, LoadPList(filename))
    SavePList(args.output, args.format, data)


class SubstituteAction(Action):
  """Class implementing the variable substitution in a plist file."""

  name = 'substitute'
  help = 'perform pattern substitution in a plist file'

  @staticmethod
  def _Register(parser):
    parser.add_argument('-o',
                        '--output',
                        required=True,
                        help='path to the output plist file')
    parser.add_argument('-t',
                        '--template',
                        required=True,
                        help='path to the template file')
    parser.add_argument('-s',
                        '--substitution',
                        action='append',
                        default=[],
                        help='substitution rule in the format key=value')
    parser.add_argument('-f',
                        '--format',
                        required=True,
                        choices=('xml1', 'binary1'),
                        help='format of the plist file to generate')
    parser.add_argument(
        '-x',
        '--xcode-version',
        help='version of Xcode, ignored (can be used to force rebuild)')

  @staticmethod
  def _Execute(args):
    substitutions = {}
    for substitution in args.substitution:
      key, value = substitution.split('=', 1)
      substitutions[key] = value
    data = Interpolate(LoadPList(args.template), substitutions)
    SavePList(args.output, args.format, data)


def Main():
  # Cache this codec so that plistlib can find it. See
  # https://crbug.com/1005190#c2 for more details.
  codecs.lookup('utf-8')

  parser = argparse.ArgumentParser(description='manipulate plist files')
  subparsers = parser.add_subparsers()

  for action in [MergeAction, SubstituteAction]:
    action.Register(subparsers)

  args = parser.parse_args()
  args.func(args)


if __name__ == '__main__':
  # TODO(https://crbug.com/941669): Temporary workaround until all scripts use
  # python3 by default.
  if sys.version_info[0] < 3:
    os.execvp('python3', ['python3'] + sys.argv)
  sys.exit(Main())
