# 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

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|.

    Args:
        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|.

    Args:
        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())
