#!/usr/bin/env python3
"""Build and sign this dependency-free app using installed Android SDK tools.

No server, device, Gradle download, or PHP file is accessed by this build.
Signing credentials must be stored outside the project and source archive.
"""
import argparse
import hashlib
import os
from pathlib import Path
import secrets
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
import zipfile

ROOT = Path(__file__).resolve().parent
ANDROID_NS = 'http://schemas.android.com/apk/res/android'


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--sdk', default=os.environ.get('ANDROID_HOME') or str(Path.home() / 'Library/Android/sdk'))
    parser.add_argument('--java-home', default=os.environ.get('JAVA_HOME') or '/Applications/Android Studio.app/Contents/jbr/Contents/Home')
    parser.add_argument('--signing-dir', required=True, help='Private folder outside the project and website directory')
    parser.add_argument('--create-key', action='store_true', help='Create a NEW signing identity on the first build only')
    args = parser.parse_args()
    config = dict(line.split('=', 1) for line in (ROOT / 'version.properties').read_text().splitlines() if line and not line.startswith('#'))
    sdk, java_home = Path(args.sdk).expanduser().resolve(), Path(args.java_home).expanduser().resolve()
    signing = Path(args.signing_dir).expanduser().resolve()
    if signing == ROOT.parent or ROOT.parent in signing.parents:
        parser.error('Keep the signing directory outside the website/project directory.')
    platform = 'android-' + config['compileSdk'] + ('.' + config['compileMinor'] if config['compileMinor'] != '0' else '')
    android_jar = sdk / 'platforms' / platform / 'android.jar'
    tools = sdk / 'build-tools' / config['buildTools']
    required = [android_jar] + [tools / name for name in ['aapt2', 'd8', 'zipalign', 'apksigner']]
    required += [java_home / 'bin' / name for name in ['java', 'javac', 'jar', 'keytool']]
    for file in required:
        if not file.is_file(): parser.error('Missing build tool: ' + str(file))
    env = os.environ.copy()
    env['JAVA_HOME'] = str(java_home)

    def run(argv, capture=False):
        result = subprocess.run([str(arg) for arg in argv], cwd=ROOT, env=env,
                                check=True, text=True, stdout=subprocess.PIPE if capture else None)
        return result.stdout or ''

    keystore = signing / 'kitchen-release.p12'
    password_file = signing / 'keystore.password'
    if args.create_key:
        if keystore.exists() or password_file.exists():
            parser.error('Signing files already exist. Rebuild WITHOUT --create-key to retain the update identity.')
        signing.mkdir(parents=True, exist_ok=True, mode=0o700)
        signing.chmod(0o700)
        with os.fdopen(os.open(password_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'w') as handle:
            handle.write(secrets.token_urlsafe(36) + '\n')
        run([java_home / 'bin/keytool', '-genkeypair', '-noprompt', '-keystore', keystore,
             '-storetype', 'PKCS12', '-alias', 'kitchen-release', '-keyalg', 'RSA', '-keysize', '3072',
             '-sigalg', 'SHA256withRSA', '-validity', '10000', '-dname', 'CN=Kitchen Store 2',
             '-storepass:file', password_file, '-keypass:file', password_file])
        keystore.chmod(0o600)
        (signing / 'README.txt').write_text(
            'PRIVATE AND REQUIRED FOR FUTURE UPDATES\n\n'
            'Back up kitchen-release.p12 and keystore.password together in a private location.\n'
            'Do not upload this folder to the website or distribute it with the APK/source.\n'
            'Package: ' + config['applicationId'] + '\nAlias: kitchen-release\n'
            'Reuse this identity and increase versionCode for future in-place updates.\n')
    if not keystore.is_file() or not password_file.is_file():
        parser.error('Existing signing files not found. Use --create-key only for a first installation.')

    build = ROOT / 'build' / 'sdk'
    if build.exists(): shutil.rmtree(build)
    for folder in ['generated', 'classes', 'dex', 'tests']:
        (build / folder).mkdir(parents=True, exist_ok=True)
    dist = ROOT / 'dist'
    dist.mkdir(exist_ok=True)
    source = ROOT / 'app/src/main'

    # The Gradle manifest omits package; the direct SDK build supplies it here.
    ET.register_namespace('android', ANDROID_NS)
    manifest = ET.parse(source / 'AndroidManifest.xml')
    manifest.getroot().set('package', config['applicationId'])
    manifest.write(build / 'AndroidManifest.xml', encoding='utf-8', xml_declaration=True)
    print('Compiling resources and Java…', flush=True)
    run([tools / 'aapt2', 'compile', '--dir', source / 'res', '-o', build / 'resources.zip'])
    run([tools / 'aapt2', 'link', '-o', build / 'unsigned.apk', '-I', android_jar,
         '--manifest', build / 'AndroidManifest.xml', '--java', build / 'generated',
         '--min-sdk-version', config['minSdk'], '--target-sdk-version', config['targetSdk'],
         '--version-code', config['versionCode'], '--version-name', config['versionName'],
         build / 'resources.zip'])
    sources = sorted((source / 'java').rglob('*.java')) + sorted((build / 'generated').rglob('*.java'))
    # Use JDK Java 8 boot classes for lambda compilation; D8 desugars for Android.
    run([java_home / 'bin/javac', '-encoding', 'UTF-8', '--release', '8',
         '-Xlint:-options', '-classpath', android_jar, '-d', build / 'classes'] + sources)
    run([java_home / 'bin/jar', '--create', '--file', build / 'classes.jar', '-C', build / 'classes', '.'])
    run([tools / 'd8', '--release', '--min-api', config['minSdk'], '--lib', android_jar,
         '--output', build / 'dex', build / 'classes.jar'])
    with zipfile.ZipFile(build / 'unsigned.apk', 'a', zipfile.ZIP_DEFLATED) as archive:
        for dex in sorted((build / 'dex').glob('*.dex')): archive.write(dex, dex.name)
    run([tools / 'zipalign', '-f', '4', build / 'unsigned.apk', build / 'aligned.apk'])

    print('Checking navigation restrictions…', flush=True)
    policy = source / 'java/sg/selfservice/bigbait/kitchen/KitchenUrl.java'
    run([java_home / 'bin/javac', '-encoding', 'UTF-8', '-d', build / 'tests', policy, ROOT / 'tests/KitchenUrlTest.java'])
    run([java_home / 'bin/java', '-cp', build / 'tests', 'sg.selfservice.bigbait.kitchen.KitchenUrlTest'])

    apk_name = 'Kitchen-Store2-' + config['versionName'] + '.apk'
    apk = dist / apk_name
    print('Signing and verifying APK…', flush=True)
    run([tools / 'apksigner', 'sign', '--ks', keystore, '--ks-key-alias', 'kitchen-release',
         '--ks-pass', 'file:' + str(password_file),
         '--v2-signing-enabled', 'true', '--v3-signing-enabled', 'true',
         '--v4-signing-enabled', 'false', '--out', apk, build / 'aligned.apk'])
    verification = run([tools / 'apksigner', 'verify', '--verbose', '--print-certs',
                        '--min-sdk-version', config['minSdk'], apk], capture=True)
    run([tools / 'zipalign', '-c', '4', apk])
    (dist / 'signature-verification.txt').write_text(verification)
    metadata = run([tools / 'aapt2', 'dump', 'badging', apk], capture=True)
    (dist / 'apk-details.txt').write_text(metadata)
    for expected in ["package: name='" + config['applicationId'] + "'",
                     "minSdkVersion:'" + config['minSdk'] + "'",
                     "targetSdkVersion:'" + config['targetSdk'] + "'",
                     "launchable-activity: name='sg.selfservice.bigbait.kitchen.MainActivity'"]:
        if expected not in metadata: raise RuntimeError('Unexpected APK metadata: ' + expected)
    with zipfile.ZipFile(apk) as archive:
        if 'classes.dex' not in archive.namelist(): raise RuntimeError('APK is missing compiled code')

    bundle_name = 'Kitchen-Store2-source-' + config['versionName'] + '.zip'
    bundle = dist / bundle_name
    excluded = {'.gradle', '.idea', 'build', 'dist', '__pycache__'}
    with zipfile.ZipFile(bundle, 'w', zipfile.ZIP_DEFLATED) as archive:
        for file in sorted(ROOT.rglob('*')):
            relative = file.relative_to(ROOT)
            if any(part in excluded for part in relative.parts) or not file.is_file(): continue
            if file.name == 'local.properties' or file.suffix in {'.p12', '.jks', '.keystore', '.password'}: continue
            archive.write(file, Path('android-kitchen') / relative)
    (dist / 'SHA256SUMS.txt').write_text(''.join(
        hashlib.sha256(file.read_bytes()).hexdigest() + '  ' + file.name + '\n' for file in [apk, bundle]))
    print(verification)
    print('APK: ' + str(apk) + ' (' + str(apk.stat().st_size) + ' bytes)')
    print('Source: ' + str(bundle))


if __name__ == '__main__':
    try:
        main()
    except subprocess.CalledProcessError as error:
        sys.exit(error.returncode)
