#!/usr/bin/env python2
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>

import json
import os
import plistlib
import re
import shlex
import subprocess
import tempfile
import time
from contextlib import contextmanager
from glob import glob
from pprint import pprint
from urllib.request import urlopen
from uuid import uuid4

from bypy.utils import current_dir, run_shell, timeit

CODESIGN_CREDS = os.path.expanduser('~/cert-cred')
CODESIGN_CERT = os.path.expanduser('~/maccert.p12')
# The apple id file contains the apple id and an app specific password which
# can be generated from appleid.apple.com
# Note that apple accounts require two-factor authentication which is currntly
# setup on ox and via SMS on my phone
APPLE_ID = os.path.expanduser('~/aid')
path_to_entitlements = os.path.expanduser('~/calibre-entitlements.plist')


def run(*args):
    if len(args) == 1 and isinstance(args[0], str):
        args = shlex.split(args[0])
    if subprocess.call(args) != 0:
        raise SystemExit('Failed: {}'.format(args))


@contextmanager
def make_certificate_useable():
    KEYCHAIN = tempfile.NamedTemporaryFile(suffix='.keychain', dir=os.path.expanduser('~'), delete=False).name
    os.remove(KEYCHAIN)
    KEYCHAIN_PASSWORD = '{}'.format(uuid4())
    # Create temp keychain
    run('security create-keychain -p "{}" "{}"'.format(KEYCHAIN_PASSWORD, KEYCHAIN))
    # Append temp keychain to the user domain
    raw = subprocess.check_output('security list-keychains -d user'.split()).decode('utf-8')
    existing_keychain = raw.replace('"', '').strip()
    run('security list-keychains -d user -s "{}" "{}"'.format(KEYCHAIN, existing_keychain))
    try:
        # Remove relock timeout
        run('security set-keychain-settings "{}"'.format(KEYCHAIN))
        # Unlock keychain
        run('security unlock-keychain -p "{}" "{}"'.format(KEYCHAIN_PASSWORD, KEYCHAIN))
        # Add certificate to keychain
        with open(CODESIGN_CREDS, 'r') as f:
            cert_pass = f.read().strip()
        # Add certificate to keychain and allow codesign to use it
        # Use -A instead of -T /usr/bin/codesign to allow all apps to use it
        run('security import {} -k "{}" -P "{}" -T "/usr/bin/codesign"'.format(
            CODESIGN_CERT, KEYCHAIN, cert_pass))
        raw = subprocess.check_output([
            'security', 'find-identity', '-v', '-p', 'codesigning', KEYCHAIN]).decode('utf-8')
        cert_id = re.search(r'"([^"]+)"', raw).group(1)
        # Enable codesigning from a non user interactive shell
        run('security set-key-partition-list -S apple-tool:,apple: -s -k "{}" -D "{}" -t private "{}"'.format(
            KEYCHAIN_PASSWORD, cert_id, KEYCHAIN))
        yield
    finally:
        # Delete temporary keychain
        run('security delete-keychain "{}"'.format(KEYCHAIN))


def codesign(items):
    if isinstance(items, str):
        items = [items]
    # If you get errors while codesigning that look like "A timestamp was
    # expected but not found" it means that codesign  failed to contact Apple's time
    # servers, probably due to network congestion
    #
    # --options=runtime enables the Hardened Runtime
    subprocess.check_call([
        'codesign', '--options=runtime', '--entitlements=' + path_to_entitlements,
        '--timestamp', '-s', 'Kovid Goyal'
    ] + list(items))


def notarize_app(app_path):
    # See
    # https://developer.apple.com/documentation/xcode/notarizing_your_app_before_distribution/customizing_the_notarization_workflow?language=objc
    # and
    # https://developer.apple.com/documentation/xcode/notarizing_your_app_before_distribution/resolving_common_notarization_issues?language=objc
    with open(APPLE_ID) as f:
        un, pw = f.read().strip().split(':')

    with open(os.path.join(app_path, 'Contents', 'Info.plist'), 'rb') as f:
        primary_bundle_id = plistlib.load(f)['CFBundleIdentifier']

    zip_path = os.path.join(os.path.dirname(app_path), 'calibre.zip')
    print('Creating zip file for notarization')
    with timeit() as times:
        run('ditto', '-c', '-k', '--zlibCompressionLevel', '9', '--keepParent', app_path, zip_path)
    print('ZIP file of {} MB created in {} minutes and {} seconds'.format(os.path.getsize(zip_path) // 1024**2, *times))

    def altool(*args):
        args = ['xcrun', 'altool'] + list(args) + ['--username', un, '--password', pw]
        p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stdout, stderr = p.communicate()
        stdout = stdout.decode('utf-8')
        stderr = stderr.decode('utf-8')
        output = stdout + '\n' + stderr
        print(output)
        if p.wait() != 0:
            print('The command {} failed with error code: {}'.format(args, p.returncode))
            try:
                run_shell()
            finally:
                raise SystemExit(1)
        return output

    print('Submitting for notarization')
    with timeit() as times:
        try:
            stdout = altool('--notarize-app', '-f', zip_path, '--primary-bundle-id', primary_bundle_id)
        finally:
            os.remove(zip_path)
        request_id = re.search(r'RequestUUID = (\S+)', stdout).group(1)
        status = 'in progress'
    print('Submission done in {} minutes and {} seconds'.format(*times))

    print('Waiting for notarization')
    with timeit() as times:
        start_time = time.monotonic()
        while status == 'in progress':
            time.sleep(30)
            print('Checking if notarization is complete, time elapsed: {:.1f} seconds'.format(time.monotonic() - start_time))
            stdout = altool('--notarization-info', request_id)
            status = re.search(r'Status\s*:\s+(.+)', stdout).group(1).strip()
    print('Notarization done in {} minutes and {} seconds'.format(*times))

    if status.lower() != 'success':
        log_url = re.search(r'LogFileURL\s*:\s+(.+)', stdout).group(1).strip()
        if log_url != '(null)':
            log = json.loads(urlopen(log_url).read())
            pprint(log)
        raise SystemExit('Notarization failed, see JSON log above')
    with timeit() as times:
        print('Stapling notarization ticket')
        run('xcrun', 'stapler', 'staple', '-v', app_path)
        run('xcrun', 'stapler', 'validate', '-v', app_path)
        run('spctl', '--verbose=4', '--assess', '--type', 'execute', app_path)
    print('Stapling took {} minutes and {} seconds'.format(*times))


def files_in(folder):
    for record in os.walk(folder):
        for f in record[-1]:
            yield os.path.join(record[0], f)


def expand_dirs(items, exclude=lambda x: x.endswith('.so')):
    items = set(items)
    dirs = set(x for x in items if os.path.isdir(x))
    items.difference_update(dirs)
    for x in dirs:
        items.update({y for y in files_in(x) if not exclude(y)})
    return items


def get_executable(info_path):
    with open(info_path, 'rb') as f:
        return plistlib.load(f)['CFBundleExecutable']


def create_entitlements_file():
    ans = {
        # MAP_JIT is used by libpcre which is bundled with Qt
        'com.apple.security.cs.allow-jit': True,

        # v8 and therefore WebEngine need this as they dont use MAP_JIT
        'com.apple.security.cs.allow-unsigned-executable-memory': True,

        # calibre itself does not use DYLD env vars, but dont know about its
        # dependencies.
        'com.apple.security.cs.allow-dyld-environment-variables': True,

        # Allow loading of unsigned plugins or frameworks
        # 'com.apple.security.cs.disable-library-validation': True,
    }
    with open(path_to_entitlements, 'wb') as f:
        f.write(plistlib.dumps(ans))


def find_sub_apps(contents_dir='.'):
    for app in glob(os.path.join(contents_dir, '*.app')):
        cdir = os.path.join(app, 'Contents')
        for sapp in find_sub_apps(cdir):
            yield sapp
        yield app


def sign_MacOS(contents_dir='.'):
    # Sign everything in MacOS except the main executable
    # which will be signed automatically by codesign when
    # signing the app bundles
    with current_dir(os.path.join(contents_dir, 'MacOS')):
        exe = get_executable('../Info.plist')
        items = {x for x in os.listdir('.') if x != exe and not os.path.islink(x)}
        if items:
            codesign(items)


def do_sign_app(appdir):
    appdir = os.path.abspath(appdir)
    with current_dir(os.path.join(appdir, 'Contents')):
        sign_MacOS()
        # Sign the sub application bundles
        sub_apps = list(find_sub_apps())
        sub_apps.append('Frameworks/QtWebEngineCore.framework/Versions/Current/Helpers/QtWebEngineProcess.app')
        for sa in sub_apps:
            sign_MacOS(os.path.join(sa, 'Contents'))
        codesign(sub_apps)

        # Sign all .so files
        so_files = {x for x in files_in('.') if x.endswith('.so')}
        codesign(so_files)

        # Sign everything in PlugIns
        with current_dir('PlugIns'):
            items = set(os.listdir('.'))
            codesign(expand_dirs(items))

        # Sign everything else in Frameworks
        with current_dir('Frameworks'):
            fw = set(glob('*.framework'))
            codesign(fw)
            items = set(os.listdir('.')) - fw
            codesign(expand_dirs(items))

    # Now sign the main app
    codesign(appdir)
    # Verify the signature
    run('codesign', '-vvv', '--deep', '--strict', appdir)
    run('spctl', '--verbose=4', '--assess', '--type', 'execute', appdir)

    return 0


def sign_app(appdir, notarize):
    create_entitlements_file()
    with make_certificate_useable():
        do_sign_app(appdir)
        if notarize:
            notarize_app(appdir)
