# -*- coding: utf-8 -*-
""" Represents a bundle. In the words of the Apple docs, it's a convenient way to deliver
    software. Really it's a particular kind of directory structure, with one main executable,
    well-known places for various data files and libraries,
    and tracking hashes of all those files for signing purposes.

    For isign, we have two main kinds of bundles: the App, and the Framework (a reusable
    library packaged along with its data files.) An App may contain many Frameworks, but
    a Framework has to be re-signed independently.

    See the Apple Developer Documentation "About Bundles" """

import biplist
import code_resources
from exceptions import NotMatched
import copy
import glob
import logging
import os
from os.path import basename, exists, join, splitext
from signer import openssl_command
import signable
import shutil
import utils

log = logging.getLogger(__name__)


def is_info_plist_native(plist):
    """ If an bundle is for native iOS, it has these properties in the Info.plist """
    return (
        'CFBundleSupportedPlatforms' in plist and
        'iPhoneOS' in plist['CFBundleSupportedPlatforms']
    )


class Bundle(object):
    """ A bundle is a standard directory structure, a signable, installable set of files.
        Apps are Bundles, but so are some kinds of Frameworks (libraries) """
    helpers = []
    signable_class = None
    entitlements_path = None  # Not set for every bundle type

    def __init__(self, path):
        # Remove Control character
        path = utils.remove_control_char(path)
        self.path = path
        self.info_path = join(self.path, 'Info.plist')
        if not exists(self.info_path):
            raise NotMatched("no Info.plist found; probably not a bundle")
        self.info = biplist.readPlist(self.info_path)
        self.orig_info = None
        if not is_info_plist_native(self.info):
            # while we should probably not allow this *or* add it ourselves, it appears to work without it
            log.debug(u"Missing/invalid CFBundleSupportedPlatforms value in {}".format(self.info_path))
        # will be added later
        self.seal_path = None

    def get_entitlements_path(self):
        return self.entitlements_path

    def get_executable_path(self):
        """ Path to the main executable. For an app, this is app itself. For
            a Framework, this is the main framework """
        executable_name = None
        if 'CFBundleExecutable' in self.info:
            executable_name = self.info['CFBundleExecutable']
        else:
            executable_name, _ = splitext(basename(self.path))
        executable_name = utils.remove_control_char(executable_name)
        executable = join(self.path, executable_name)
        if not exists(executable):
            raise Exception(
                'could not find executable for {0}'.format(self.path))
        return executable

    def update_info_props(self, new_props):
        if self.orig_info is None:
            self.orig_info = copy.deepcopy(self.info)

        changed = False
        if ('CFBundleIdentifier' in new_props and
                'CFBundleURLTypes' in self.info and
                'CFBundleURLTypes' not in new_props):
            # The bundle identifier changed. Check CFBundleURLTypes for
            # CFBundleURLName values matching the old bundle
            # id if it's not being set explicitly
            old_bundle_id = self.info['CFBundleIdentifier']
            new_bundle_id = new_props['CFBundleIdentifier']
            for url_type in self.info['CFBundleURLTypes']:
                if 'CFBundleURLName' not in url_type:
                    continue
                if url_type['CFBundleURLName'] == old_bundle_id:
                    url_type['CFBundleURLName'] = new_bundle_id
                    changed = True

        for key, val in new_props.iteritems():
            is_new_key = key not in self.info
            if is_new_key or self.info[key] != val:
                if is_new_key:
                    log.warn("Adding new Info.plist key: {}".format(key))
                self.info[key] = val
                changed = True

        if changed:
            biplist.writePlist(self.info, self.info_path, binary=True)
        else:
            self.orig_info = None

    def info_props_changed(self):
        return self.orig_info is not None

    def info_prop_changed(self, key):
        if not self.orig_info:
            # No props have been changed
            return False
        if key in self.info and key in self.orig_info and self.info[key] == self.orig_info[key]:
            return False
        return True

    def get_info_prop(self, key):
        return self.info[key]

    def sign_dylibs(self, signer, path):
        """ Sign all the dylibs in this directory """
        for dylib_path in glob.glob(join(path, '*.dylib')):
            dylib = signable.Dylib(self, dylib_path, signer)
            dylib.sign(self, signer)

    def sign(self, signer):
        """ Sign everything in this bundle, recursively with sub-bundles """
        # log.debug("SIGNING: %s" % self.path)
        frameworks_path = join(self.path, 'Frameworks')
        if exists(frameworks_path):
            # log.debug("SIGNING FRAMEWORKS: %s" % frameworks_path)
            # sign all the frameworks
            for framework_name in os.listdir(frameworks_path):
                framework_path = join(frameworks_path, framework_name)
                # log.debug("checking for framework: %s" % framework_path)
                try:
                    framework = Framework(framework_path)
                    # log.debug("resigning: %s" % framework_path)
                    framework.resign(signer)
                except NotMatched:
                    # log.debug("not a framework: %s" % framework_path)
                    continue
            # sign all the dylibs under Frameworks
            self.sign_dylibs(signer, frameworks_path)

        # sign any dylibs in the main directory (rare, but it happens)
        self.sign_dylibs(signer, self.path)

        plugins_path = join(self.path, 'PlugIns')
        if exists(plugins_path):
            # sign the appex executables
            appex_paths = glob.glob(join(plugins_path, '*.appex'))
            for appex_path in appex_paths:
                plist_path = join(appex_path, 'Info.plist')
                if not exists(plist_path):
                    continue
                plist = biplist.readPlist(plist_path)
                appex_exec_path = join(appex_path, plist['CFBundleExecutable'])
                appex = signable.Appex(self, appex_exec_path, signer)
                appex.sign(self, signer)

        # then create the seal
        # TODO maybe the app should know what its seal path should be...
        self.seal_path = code_resources.make_seal(self.get_executable_path(),
                                                  self.path)
        # then sign the app
        executable = self.signable_class(self, self.get_executable_path(), signer)
        executable.sign(self, signer)

    def resign(self, signer):
        """ signs bundle, modifies in place """
        self.sign(signer)
        log.debug("Resigned bundle at <%s>", self.path)


class Framework(Bundle):
    """ A bundle that comprises reusable code. Similar to an app in that it has
        its own resources and metadata. Not like an app because the main executable
        doesn't have Entitlements, or an Application hash, and it doesn't have its
        own provisioning profile. """

    # the executable in this bundle will be a Framework
    signable_class = signable.Framework

    def __init__(self, path):
        super(Framework, self).__init__(path)


class App(Bundle):
    """ The kind of bundle that is visible as an app to the user.
        Contains the provisioning profile, entitlements, etc.  """

    # the executable in this bundle will be an Executable (i.e. the main
    # executable of an app)
    signable_class = signable.Executable

    def __init__(self, path):
        super(App, self).__init__(path)
        self.entitlements_path = join(self.path,
                                      'Entitlements.plist')
        self.provision_path = join(self.path,
                                   'embedded.mobileprovision')

    def provision(self, provision_path):
        shutil.copyfile(provision_path, self.provision_path)

    @staticmethod
    def extract_entitlements(provision_path):
        """ Given a path to a provisioning profile, return the entitlements
            encoded therein """
        cmd = [
            'smime',
            '-inform', 'der',
            '-verify',    # verifies content, prints verification status to STDERR,
                          #  outputs content to STDOUT. In our case, will be an XML plist
            '-noverify',  # accept self-signed certs. Not the opposite of -verify!
            '-in', provision_path
        ]
        # this command always prints 'Verification successful' to stderr.
        (profile_text, err) = openssl_command(cmd, data=None, expect_err=True)
        if err and err.strip() != 'Verification successful':
            log.error('Received unexpected error from openssl: {}'.format(err))
        plist_dict = biplist.readPlistFromString(profile_text)
        if 'Entitlements' not in plist_dict:
            log.debug('failed to get entitlements in provisioning profile')
            raise Exception('could not find Entitlements in {}'.format(provision_path))
        return plist_dict['Entitlements']

    def write_entitlements(self, entitlements):
        """ Write entitlements to self.entitlements_path. This actually doesn't matter
            to the app, it's just used later on by other parts of the signing process. """
        biplist.writePlist(entitlements, self.entitlements_path, binary=False)
        log.debug("wrote Entitlements to {0}".format(self.entitlements_path))

    def resign(self, signer, provisioning_profile, alternate_entitlements_path=None):
        """ signs app in place """

        # TODO all this mucking about with entitlements feels wrong. The entitlements_path is
        # not actually functional, it's just a way of passing it to later stages of signing.
        # Maybe we should determine entitlements data in isign/archive.py or even isign/isign.py,
        # and then embed it into Signer?

        # In the typical case, we add entitlements from the pprof into the app's signature
        if alternate_entitlements_path is None:
            # copy the provisioning profile in
            self.provision(provisioning_profile)

            entitlements = self.extract_entitlements(provisioning_profile)
        else:
            log.info("signing with alternative entitlements: {}".format(alternate_entitlements_path))
            entitlements = biplist.readPlist(alternate_entitlements_path)
        self.write_entitlements(entitlements)

        # actually resign this bundle now
        super(App, self).resign(signer)
