# 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

# 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, str):
        return InterpolateString(value, substitutions)
    return value


def LoadPList(path):
    """Loads Plist at |path| and returns it as a dictionary."""
    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 open() call does not replace the destination file but updates 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.
    try:
        os.unlink(path)
    except FileNotFoundError:
        pass
    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():
    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__':
    sys.exit(Main())
