#!/usr/bin/env python3
"""Teste une application dans une NAND indépendante et conserve les diagnostics."""
import argparse
import json
import os
from pathlib import Path
import plistlib
import shutil
import socket
import subprocess
import tempfile
import time
import zipfile


def bundle_id(package):
    if package.is_dir() and package.suffix == '.app':
        with (package / 'Info.plist').open('rb') as f:
            return plistlib.load(f)['CFBundleIdentifier']
    if package.suffix == '.ipa':
        with zipfile.ZipFile(package) as z:
            plists = [n for n in z.namelist() if n.startswith('Payload/') and
                      n.endswith('.app/Info.plist') and n.count('/') == 2]
            if len(plists) != 1:
                raise ValueError('IPA ambiguë : préciser --bundle-id')
            return plistlib.loads(z.read(plists[0]))['CFBundleIdentifier']
    raise ValueError('Préciser --bundle-id pour un paquet .deb ou un overlay')


def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument('package', type=Path, help='.app, .ipa, .deb ou overlay')
    p.add_argument('--bundle-id')
    p.add_argument('--base', default=os.environ.get('IPOD_TEST_BASE', '5H11a-agent-v12'))
    p.add_argument('--output', type=Path, help='nouveau dossier de résultats (doit être absent)')
    p.add_argument('--settle', type=float, default=3, help='attente après apparition au premier plan')
    args = p.parse_args()
    package = args.package.resolve()
    tools = Path(__file__).resolve().parent
    default_home = tools.parent if (tools.parent / 'images').is_dir() else Path.home() / 'ipod-emu'
    home = Path(os.environ.get('IPOD_EMU_HOME', default_home)).resolve()
    try:
        if not package.exists():
            raise ValueError('Application introuvable')
        identifier = args.bundle_id or bundle_id(package)
        if not isinstance(identifier, str) or not identifier or any(c in identifier for c in '\t\r\n'):
            raise ValueError('Bundle-id invalide')
        if Path(args.base).name != args.base or args.base in ('.', '..'):
            raise ValueError('Nom de firmware invalide')
        if args.settle < 0:
            raise ValueError('--settle doit être positif')
        base = home / 'images' / args.base
        if not (base / 'rootfs.img').is_file():
            raise ValueError(f'Image de base introuvable : {base}')
        # Les ports sont partagés par les sessions : ne jamais arrêter une VM tierce.
        vnc = os.environ.get('IPOD_VNC', '127.0.0.1:0')
        host, display = vnc.rsplit(':', 1)
        for address, port in [(os.environ.get('IPOD_AGENT_HOST', '127.0.0.1'),
                               int(os.environ.get('IPOD_AGENT_PORT', '7788'))),
                              (host or '127.0.0.1', 5900 + int(display))]:
            try:
                connection = socket.create_connection((address, port), timeout=1)
            except ConnectionRefusedError:
                continue
            else:
                connection.close()
                raise ValueError(f'Port {port} occupé : arrêter votre session avant ce test')
        if args.output:
            result = args.output.resolve()
            result.mkdir(parents=True, exist_ok=False)
        else:
            parent = home / 'work' / 'tests'
            parent.mkdir(parents=True, exist_ok=True)
            result = Path(tempfile.mkdtemp(prefix=time.strftime('%Y%m%d-%H%M%S-'), dir=parent))
    except (OSError, ValueError, KeyError, plistlib.InvalidFileException, zipfile.BadZipFile) as e:
        p.exit(1, f'{e}\n')

    print(f'Diagnostics : {result}', flush=True)
    session = result / 'session'
    (session / 'images').mkdir(parents=True)
    (session / 'images' / args.base).symlink_to(base, target_is_directory=True)
    for dependency in ('xpwn', 'gen-v1', 'netd'):
        (session / dependency).symlink_to(home / dependency, target_is_directory=True)
    env = dict(os.environ, IPOD_EMU_HOME=str(session),
               IPOD_QEMU=os.environ.get('IPOD_QEMU', str(home.parent / 'qemu-ios/build/qemu-system-arm')))
    # Un socket de session explicite ne doit pas viser la VM de l'utilisateur.
    env.pop('IPOD_QMP', None)
    image_name = 'test-app' if args.base != 'test-app' else 'test-app-result'
    started = False
    report = {'package': str(package), 'bundle_id': identifier, 'base': args.base, 'ok': False}

    with (result / 'workflow.log').open('w') as workflow:
        def run(tool, *argv, capture=False, timeout=360):
            r = subprocess.run([str(tools / tool), *map(str, argv)], env=env,
                               stdout=subprocess.PIPE if capture else workflow,
                               stderr=workflow, text=True, timeout=timeout)
            if capture:
                workflow.write(r.stdout)
                workflow.flush()
            if r.returncode:
                raise RuntimeError(f'{tool} {argv[0] if argv else ""} : code {r.returncode}')
            return r.stdout if capture else None

        try:
            run('ipod-mkimage', '-b', args.base, '-o', image_name, package, timeout=900)
            started = True  # nettoie également un démarrage partiel
            run('ipod-emu', 'start', '-w', image_name, '-W')
            run('ipod-agent', 'launch', identifier, '--log', '/private/var/mobile/ipod-test-app.log')
            deadline = time.monotonic() + 30
            while run('ipod-agent', 'front', capture=True, timeout=20).strip() != identifier:
                if time.monotonic() >= deadline:
                    raise RuntimeError('Application absente du premier plan après 30 s')
                time.sleep(1)
            time.sleep(args.settle)
            front = run('ipod-agent', 'front', capture=True, timeout=20).strip()
            report['front'] = front
            if front != identifier:
                raise RuntimeError('Application quittée pendant la période de stabilisation')
            report['ok'] = True
        except (OSError, RuntimeError, subprocess.TimeoutExpired) as e:
            report['error'] = str(e)
        finally:
            if started:
                for tool, argv in [('ipod-agent', ['get', '/private/var/mobile/ipod-test-app.log', result / 'app.log']),
                                   ('ipod-qmp', ['screenshot', result / 'screen.png'])]:
                    try:
                        run(tool, *argv, timeout=30)
                    except (OSError, RuntimeError, subprocess.TimeoutExpired) as e:
                        report.setdefault('diagnostic_errors', []).append(str(e))
                try:
                    run('ipod-agent', 'sync', timeout=15)
                except (OSError, RuntimeError, subprocess.TimeoutExpired):
                    pass
                try:
                    run('ipod-emu', 'stop', timeout=30)
                except (OSError, RuntimeError, subprocess.TimeoutExpired) as e:
                    report['ok'] = False
                    report['stop_error'] = str(e)
            if report.get('diagnostic_errors'):
                report['ok'] = False
            (result / 'result.json').write_text(json.dumps(report, indent=2, ensure_ascii=False) + '\n')
    print('Test réussi' if report['ok'] else 'Test échoué : consulter workflow.log et result.json')
    return 0 if report['ok'] else 1


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