#!/usr/bin/env python3
"""Verify a final IPA, provisioning profile and matching main-program dSYM on macOS.

Requires Python 3.9+, Xcode command line tools, and the delivery directory produced
by Build-IOS.ps1. It does not build, re-sign, install or upload the application.
"""
import argparse
from datetime import datetime, timezone
import fnmatch
import hashlib
import json
from pathlib import Path
import plistlib
import re
import subprocess
import sys
import tempfile
import zipfile


def run(*arguments):
    result = subprocess.run(arguments, capture_output=True)
    if result.returncode:
        message = result.stderr.decode('utf-8', errors='replace').strip()
        raise ValueError(f"Command failed ({result.returncode}): {arguments[0]}\n{message}")
    return result.stdout


def uuid_set(output):
    values = set(re.findall(r'UUID:\s*([0-9A-Fa-f-]{36})\s*\(([^)]+)\)', output))
    if not values:
        raise ValueError('No Mach-O UUID was returned by dwarfdump.')
    return {(uuid.upper(), architecture) for uuid, architecture in values}


def validate_profile(profile, entitlements, bundle_id, team_id, mode, device_udid=None, signing_certificate=None):
    expiry = profile.get('ExpirationDate')
    if not isinstance(expiry, datetime):
        raise ValueError('Provisioning profile has no expiration date.')
    expiry = expiry.replace(tzinfo=timezone.utc) if expiry.tzinfo is None else expiry.astimezone(timezone.utc)
    if expiry <= datetime.now(timezone.utc):
        raise ValueError('Provisioning profile has expired.')
    if team_id not in profile.get('TeamIdentifier', []):
        raise ValueError('Provisioning profile TeamIdentifier does not match the expected team.')
    if signing_certificate is not None and signing_certificate not in profile.get('DeveloperCertificates', []):
        raise ValueError('The actual signing certificate is not included in the provisioning profile.')
    signed_app_id = entitlements.get('application-identifier', '')
    allowed_app_id = profile.get('Entitlements', {}).get('application-identifier', '')
    if not signed_app_id.endswith('.' + bundle_id) or not fnmatch.fnmatchcase(signed_app_id, allowed_app_id):
        raise ValueError('Signed application-identifier does not match the Bundle ID / provisioning profile.')
    if entitlements.get('com.apple.developer.team-identifier') != team_id:
        raise ValueError('Signed team entitlement does not match the expected team.')
    devices = profile.get('ProvisionedDevices', [])
    all_devices = profile.get('ProvisionsAllDevices', False)
    signed_debug = entitlements.get('get-task-allow', False)
    profile_debug = profile.get('Entitlements', {}).get('get-task-allow', False)
    if mode == 'development' and (not devices or not signed_debug or not profile_debug):
        raise ValueError('Development mode requires a development profile with get-task-allow and registered devices.')
    if mode == 'ad-hoc' and (not devices or all_devices or signed_debug or profile_debug):
        raise ValueError('Ad Hoc mode requires registered devices and get-task-allow=false.')
    if mode == 'app-store' and (devices or all_devices or signed_debug or profile_debug):
        raise ValueError('App Store mode requires an App Store profile with get-task-allow=false and no device list.')
    if device_udid and mode != 'app-store' and device_udid not in devices:
        raise ValueError('The specified test device is not included in the provisioning profile.')
    return expiry.isoformat()


def validate_manifest(delivery):
    manifest_path = delivery / 'artifact-manifest.json'
    if not manifest_path.is_file():
        raise ValueError('artifact-manifest.json is missing. Use the collected delivery folder.')
    manifest = json.loads(manifest_path.read_text(encoding='utf-8-sig'))
    files = manifest.get('Files', [])
    if not files:
        raise ValueError('Artifact manifest is empty.')
    for entry in files:
        path = (delivery / entry['Path']).resolve()
        if delivery not in path.parents or not path.is_file():
            raise ValueError(f"Missing or invalid artifact path: {entry['Path']}")
        digest = sha256_file(path)
        if path.stat().st_size != entry['Bytes'] or digest.lower() != entry['SHA256'].lower():
            raise ValueError(f"Artifact changed since collection: {entry['Path']}")
    return len(files)


def sha256_file(path):
    digest = hashlib.sha256()
    with path.open('rb') as source:
        for block in iter(lambda: source.read(1024 * 1024), b''):
            digest.update(block)
    return digest.hexdigest()


def check_zip(path):
    with zipfile.ZipFile(path) as archive:
        for name in archive.namelist():
            relative = Path(name)
            if relative.is_absolute() or '..' in relative.parts:
                raise ValueError(f'Unsafe archive entry in {path.name}.')
        broken = archive.testzip()
        if broken:
            raise ValueError(f'Corrupt archive entry in {path.name}: {broken}')


def verify(args):
    if sys.platform != 'darwin':
        raise ValueError('Run this verifier on macOS with Xcode command line tools.')
    delivery = Path(args.delivery).expanduser().resolve()
    count = validate_manifest(delivery)
    ipa_files = list(delivery.glob('*.ipa'))
    symbol_zips = list((delivery / 'symbols').glob('*.dSYM.zip'))
    if len(ipa_files) != 1 or not symbol_zips:
        raise ValueError('Expected one final IPA and at least one symbols/*.dSYM.zip.')
    receipt = json.loads((delivery / 'build.target').read_text(encoding='utf-8-sig'))
    metadata = json.loads((delivery / 'build.json').read_text(encoding='utf-8-sig'))
    if receipt['Platform'] != 'IOS' or receipt['TargetName'] != metadata['TargetName'] or receipt['Configuration'] != metadata['Configuration']:
        raise ValueError('Target receipt and delivery metadata do not match.')
    if metadata['BundleIdentifier'] != args.bundle_id:
        raise ValueError('Requested Bundle ID and build metadata do not match.')
    expected_mode = {'Development': 'development', 'AdHoc': 'ad-hoc', 'AppStore': 'app-store'}[metadata['Mode']]
    if args.mode != expected_mode:
        raise ValueError('Requested signing mode and build metadata do not match.')
    for archive in [*ipa_files, *symbol_zips]:
        check_zip(archive)
    with tempfile.TemporaryDirectory(prefix='ue56-ipa-verify-') as temporary:
        work = Path(temporary)
        run('/usr/bin/ditto', '-x', '-k', str(ipa_files[0]), str(work / 'ipa'))
        for archive in symbol_zips:
            run('/usr/bin/ditto', '-x', '-k', str(archive), str(work / 'symbols'))
        applications = list((work / 'ipa/Payload').glob('*.app'))
        if len(applications) != 1:
            raise ValueError('IPA does not contain exactly one root Payload/*.app.')
        app = applications[0]
        info = plistlib.loads((app / 'Info.plist').read_bytes())
        if info.get('CFBundleIdentifier') != args.bundle_id:
            raise ValueError('The final IPA Bundle ID differs from the requested Bundle ID.')
        executable_name = info.get('CFBundleExecutable', '')
        if not executable_name or Path(executable_name).name != executable_name:
            raise ValueError('Invalid CFBundleExecutable.')
        app_uuids = uuid_set(run('/usr/bin/xcrun', 'dwarfdump', '--uuid', str(app / executable_name)).decode())
        if 'arm64' not in {architecture for _, architecture in app_uuids}:
            raise ValueError('The main executable has no arm64 device slice.')
        matches = []
        symbol_index = {}
        bundles = list((work / 'symbols').rglob('*.dSYM'))
        for bundle in bundles:
            if not (bundle / 'Contents/Info.plist').is_file():
                raise ValueError('Incomplete dSYM bundle: missing Contents/Info.plist.')
            for dwarf in (bundle / 'Contents/Resources/DWARF').glob('*'):
                if not dwarf.is_file():
                    continue
                uuids = uuid_set(run('/usr/bin/xcrun', 'dwarfdump', '--uuid', str(dwarf)).decode())
                symbol_index[dwarf.name] = uuids
                if uuids == app_uuids:
                    matches.append(bundle.name)
        if len(set(matches)) != 1:
            raise ValueError('Expected exactly one dSYM whose complete UUID/architecture set matches the final IPA executable.')
        run('/usr/bin/codesign', '--verify', '--deep', '--strict', '--verbose=2', str(app))
        entitlement_bytes = run('/usr/bin/codesign', '-d', '--entitlements', ':-', str(app))
        entitlements = plistlib.loads(entitlement_bytes)
        embedded = app / 'embedded.mobileprovision'
        if not embedded.is_file():
            raise ValueError('IPA is missing embedded.mobileprovision.')
        profile = plistlib.loads(run('/usr/bin/security', 'cms', '-D', '-i', str(embedded)))
        certificate_prefix = work / 'signing-certificate-'
        run('/usr/bin/codesign', '-d', '--extract-certificates', str(certificate_prefix), str(app))
        signing_certificate = Path(str(certificate_prefix) + '0').read_bytes()
        expiry = validate_profile(profile, entitlements, args.bundle_id, args.team_id, args.mode, args.device_udid, signing_certificate)
        framework_gaps = []
        for framework in (app / 'Frameworks').glob('*.framework'):
            framework_info = plistlib.loads((framework / 'Info.plist').read_bytes())
            framework_exe = framework_info.get('CFBundleExecutable', framework.stem)
            uuids = uuid_set(run('/usr/bin/xcrun', 'dwarfdump', '--uuid', str(framework / framework_exe)).decode())
            if not any(uuids == candidate for candidate in symbol_index.values()):
                framework_gaps.append(framework.name)
        if args.require_framework_symbols and framework_gaps:
            raise ValueError('Matching third-party framework dSYMs are missing: ' + ', '.join(framework_gaps))
        report = {
            'State': 'SIGNATURE_AND_MAIN_DSYM_VERIFIED_DEVICE_TEST_PENDING',
            'VerifiedUtc': datetime.now(timezone.utc).isoformat(),
            'IPA': ipa_files[0].name, 'SHA256': sha256_file(ipa_files[0]),
            'BundleIdentifier': args.bundle_id, 'TeamIdentifier': args.team_id, 'Mode': args.mode,
            'MainDSYM': matches[0], 'UUIDs': [{'UUID': uuid, 'Architecture': arch} for uuid, arch in sorted(app_uuids)],
            'ProfileExpirationUtc': expiry, 'ManifestFilesVerified': count,
            'FrameworkSymbolGaps': framework_gaps,
            'DeviceAcceptance': 'Not performed by this script; install, launch and test the app separately.',
        }
        (delivery / 'verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
        return report


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('delivery')
    parser.add_argument('--bundle-id', required=True)
    parser.add_argument('--team-id', required=True)
    parser.add_argument('--mode', choices=['development', 'ad-hoc', 'app-store'], required=True)
    parser.add_argument('--device-udid', help='Confirm this device is included in Development / Ad Hoc provisioning.')
    parser.add_argument('--require-framework-symbols', action='store_true', help='Also fail if embedded frameworks lack matching supplied dSYMs.')
    args = parser.parse_args()
    delivery = Path(args.delivery).expanduser().resolve()
    # Remove only this script's old report so a failed recheck cannot leave a stale success result.
    if sys.platform == 'darwin' and delivery.is_dir():
        (delivery / 'verification.json').unlink(missing_ok=True)
    try:
        print(json.dumps(verify(args), ensure_ascii=False, indent=2))
    except (ValueError, OSError, KeyError, zipfile.BadZipFile, plistlib.InvalidFileException) as error:
        print(f'VERIFICATION FAILED: {error}', file=sys.stderr)
        return 1
    return 0


if __name__ == '__main__':
    raise SystemExit(main())
