#!/usr/bin/env python3
# MobileGL - tools/piglit-android/run_piglit_android.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
#   https://www.gnu.org/licenses/gpl-3.0.txt
#   https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Run piglit GL tests on a connected Android device against MobileGL.

Pipeline:
  1. Read a test list produced by `piglit print-cmd` ("name ::: command" lines).
  2. Rewrite host paths to on-device paths and collect referenced data files.
  3. Push binaries/libs/data to the device (incremental, marker-based).
  4. Execute tests serially on-device in chunked shell scripts under `timeout`,
     with MobileGL served to waffle via WAFFLE_EGL_LIBRARY=libMobileGL.so.
  5. Parse "PIGLIT: {...}" result lines, classify, and write results + summary.

The device never needs python or an APK; everything runs as the adb shell user
from /data/local/tmp.
"""

import argparse
import json
import os
import re
import shlex
import subprocess
import sys
import tarfile
import tempfile
import time
from pathlib import Path

RESULT_LINE = re.compile(rb'PIGLIT: *({.*})')
MARK_START = re.compile(rb'@@@T (\d+) START')
MARK_EXIT = re.compile(rb'@@@T (\d+) EXIT (\d+)')

# toybox timeout exit codes: 124 = timed out (SIGTERM), 137 = SIGKILL after -k.
TIMEOUT_EXITS = {124, 137, 142}


def adb(args, serial=None, **kw):
    cmd = ['adb'] + (['-s', serial] if serial else []) + args
    return subprocess.run(cmd, **kw)


def adb_check(args, serial=None):
    r = adb(args, serial=serial, capture_output=True)
    if r.returncode != 0:
        raise RuntimeError(f"adb {' '.join(args)} failed: {r.stderr.decode(errors='replace')}")
    return r.stdout


def parse_list(path):
    tests = []
    for line in Path(path).read_text().splitlines():
        line = line.strip()
        if not line or ' ::: ' not in line:
            continue
        name, cmd = line.split(' ::: ', 1)
        tests.append((name.strip(), shlex.split(cmd)))
    return tests


def rewrite_cmd(argv, piglit_root, build_dir, device_dir):
    """Map host paths in a test command to device paths.

    Returns (device_argv, referenced_host_files).
    """
    build_abs = str((piglit_root / build_dir).resolve())
    src_abs = str(piglit_root.resolve())
    out = []
    refs = []
    for i, arg in enumerate(argv):
        a = arg
        if i == 0:
            # program: "build-android/bin/foo" or absolute
            prog = a if os.path.isabs(a) else str((piglit_root / a).resolve())
            refs.append(prog)
            out.append(f'{device_dir}/bin/{os.path.basename(prog)}')
            continue
        p = a if os.path.isabs(a) else None
        if p and p.startswith(build_abs + '/generated_tests/'):
            refs.append(p)
            out.append(p.replace(build_abs + '/generated_tests', device_dir + '/generated_tests', 1))
        elif p and p.startswith(build_abs + '/tests/'):
            # Serialized profiles record data paths under the build dir, but
            # only *generated* data lives there in an out-of-tree build;
            # plain test files stay in the source tests/ dir.
            if not os.path.exists(p):
                p = p.replace(build_abs + '/tests', src_abs + '/tests', 1)
                refs.append(p)
                out.append(p.replace(src_abs + '/tests', device_dir + '/tests', 1))
            else:
                refs.append(p)
                out.append(p.replace(build_abs + '/tests', device_dir + '/build-tests', 1))
        elif p and p.startswith(src_abs + '/tests/'):
            refs.append(p)
            out.append(p.replace(src_abs + '/tests', device_dir + '/tests', 1))
        else:
            out.append(a)
    return out, refs


def backend_env(backend, device_dir, use_angle=False):
    env = {
        'LD_LIBRARY_PATH': f'{device_dir}/lib',
        'WAFFLE_EGL_LIBRARY': 'libMobileGL.so',
        'WAFFLE_GL_LIBRARY': 'libMobileGL.so',
        # Upgrade low compat context requests to what MobileGL implements;
        # without this, piglit's supports_gl_compat_version=10 tests get the
        # raw backend version string and skip themselves.
        'WAFFLE_FORCE_GL_CONTEXT_VERSION': '33core',
        'PIGLIT_PLATFORM': 'surfaceless_egl',
        'PIGLIT_SOURCE_DIR': device_dir,
        'MOBILEGL_BACKEND_TYPE': backend,
        'MOBILEGL_LOG_FILE_PATH': f'{device_dir}/mobilegl.log',
    }
    if backend == 'DirectGLES':
        env['MOBILEGL_ESPRYT_USE_ANGLE'] = '1' if use_angle else '0'
    if backend == 'DirectVulkan':
        # The device ICD usually lacks VK_EXT_headless_surface, which the
        # MobileGL pbuffer path needs; use a real ANativeWindow from
        # AImageReader instead (patched waffle surfaceless_egl platform).
        env['WAFFLE_ANDROID_WINDOW'] = 'imagereader'
    return env


def push_tree(host, dev, serial, marker_name, force=False):
    """Push a directory once, tracked by a marker file on the device."""
    marker = f'{dev}.pushed-{marker_name}'
    if not force:
        r = adb(['shell', f'test -e {shlex.quote(marker)} && echo yes'],
                serial=serial, capture_output=True)
        if r.stdout.strip() == b'yes':
            return False
    print(f'  pushing {host} -> {dev}')
    adb_check(['shell', f'rm -rf {shlex.quote(dev)}'], serial=serial)
    adb_check(['push', str(host), dev], serial=serial)
    adb_check(['shell', f'touch {shlex.quote(marker)}'], serial=serial)
    return True


def push_files(files, strip_prefix, dev_prefix, serial):
    """Tar up referenced data files (relative to strip_prefix) and unpack on device."""
    files = sorted(set(files))
    if not files:
        return
    with tempfile.NamedTemporaryFile(suffix='.tar', delete=False) as tf:
        tar_path = tf.name
    with tarfile.open(tar_path, 'w') as tar:
        for f in files:
            rel = os.path.relpath(f, strip_prefix)
            if rel.startswith('..'):
                raise RuntimeError(f'file {f} not under {strip_prefix}')
            tar.add(f, arcname=rel)
    dev_tar = dev_prefix + '/.data.tar'
    adb_check(['shell', f'mkdir -p {shlex.quote(dev_prefix)}'], serial=serial)
    adb_check(['push', tar_path, dev_tar], serial=serial)
    adb_check(['shell', f'cd {shlex.quote(dev_prefix)} && tar xf .data.tar && rm .data.tar'],
              serial=serial)
    os.unlink(tar_path)


def classify(exit_code, piglit_results, timed_out):
    if timed_out:
        return 'timeout'
    result = None
    for r in piglit_results:
        if 'result' in r:
            result = r['result']
    if result is None:
        return 'crash' if exit_code != 0 else 'notrun'
    if exit_code not in (0, 1):
        return 'crash'
    return result


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument('--piglit-root', required=True, type=Path,
                    help='piglit source checkout (with the Android build dir inside)')
    ap.add_argument('--build-dir', default='build-android',
                    help='Android build dir name inside piglit root')
    ap.add_argument('--list', required=True,
                    help='test list file from `piglit print-cmd` (name ::: cmd)')
    ap.add_argument('--backend', required=True, choices=['DirectGLES', 'DirectVulkan'])
    ap.add_argument('--use-angle', action='store_true',
                    help='DirectGLES only: let MobileGL load ANGLE instead of the system driver')
    ap.add_argument('--mobilegl-lib', required=True, type=Path,
                    help='stripped libMobileGL.so for the device')
    ap.add_argument('--waffle-lib', required=True, type=Path,
                    help='cross-built libwaffle-1.so')
    ap.add_argument('--device-dir', default='/data/local/tmp/piglit-mgl')
    ap.add_argument('--out', required=True, type=Path, help='host results directory')
    ap.add_argument('--serial', default=None, help='adb device serial')
    ap.add_argument('--timeout', type=int, default=60, help='per-test timeout (seconds)')
    ap.add_argument('--chunk', type=int, default=200, help='tests per device-side script')
    ap.add_argument('--repush', action='store_true', help='force re-push of bin/lib/tests trees')
    args = ap.parse_args()

    piglit_root = args.piglit_root.resolve()
    build = piglit_root / args.build_dir
    dev = args.device_dir.rstrip('/')
    args.out.mkdir(parents=True, exist_ok=True)

    tests = parse_list(args.list)
    if not tests:
        print('no tests in list', file=sys.stderr)
        return 2

    print(f'[1/4] {len(tests)} tests; rewriting paths')
    dev_cmds = []
    data_refs = []
    for name, argv in tests:
        dcmd, refs = rewrite_cmd(argv, piglit_root, args.build_dir, dev)
        dev_cmds.append((name, dcmd))
        data_refs.extend(r for r in refs if not r.split('/')[-2:][0] == 'bin')

    print('[2/4] pushing artifacts')
    adb_check(['shell', f'mkdir -p {dev} {dev}/lib {dev}/chunks {dev}/logs'], serial=args.serial)
    push_tree(build / 'bin', f'{dev}/bin', args.serial, 'bin', args.repush)
    adb_check(['shell', f'chmod -R 755 {dev}/bin'], serial=args.serial)
    # libs: piglit utils + waffle + MobileGL in one LD_LIBRARY_PATH dir
    for lib in sorted((build / 'lib').glob('*.so')):
        adb_check(['push', str(lib), f'{dev}/lib/'], serial=args.serial)
    adb_check(['push', str(args.waffle_lib), f'{dev}/lib/libwaffle-1.so'], serial=args.serial)
    adb_check(['push', str(args.mobilegl_lib), f'{dev}/lib/libMobileGL.so'], serial=args.serial)
    # data files referenced by this run (tests/, build-tests/, generated_tests/)
    src_abs = str(piglit_root)
    build_abs = str(build)
    groups = {
        (src_abs + '/tests', f'{dev}/tests'): [],
        (build_abs + '/tests', f'{dev}/build-tests'): [],
        (build_abs + '/generated_tests', f'{dev}/generated_tests'): [],
    }
    for r in set(data_refs):
        for (host_prefix, dev_prefix), bucket in groups.items():
            if r.startswith(host_prefix + '/'):
                bucket.append(r)
                break
    for (host_prefix, dev_prefix), bucket in groups.items():
        push_files(bucket, host_prefix, dev_prefix, args.serial)

    env = backend_env(args.backend, dev, args.use_angle)
    env_lines = '\n'.join(f'export {k}={shlex.quote(v)}' for k, v in env.items())

    print(f'[3/4] running {len(tests)} tests on {args.backend} '
          f'(timeout {args.timeout}s/test, chunks of {args.chunk})')
    raw_log = (args.out / 'raw.log').open('wb')
    t0 = time.time()
    outcomes = {}
    chunks = [dev_cmds[i:i + args.chunk] for i in range(0, len(dev_cmds), args.chunk)]
    idx_base = 0
    for ci, chunk in enumerate(chunks):
        lines = ['#!/system/bin/sh', env_lines, f'cd {dev}']
        for j, (name, dcmd) in enumerate(chunk):
            idx = idx_base + j
            quoted = ' '.join(shlex.quote(a) for a in dcmd)
            lines.append(f'echo "@@@T {idx} START"')
            lines.append(f'timeout -k 5 {args.timeout} {quoted} </dev/null 2>&1')
            lines.append(f'echo "@@@T {idx} EXIT $?"')
        script = '\n'.join(lines) + '\n'
        with tempfile.NamedTemporaryFile('w', suffix='.sh', delete=False) as tf:
            tf.write(script)
            host_script = tf.name
        dev_script = f'{dev}/chunks/chunk{ci}.sh'
        adb_check(['push', host_script, dev_script], serial=args.serial)
        os.unlink(host_script)
        r = adb(['shell', f'sh {dev_script}'], serial=args.serial, capture_output=True)
        raw_log.write(r.stdout)
        raw_log.flush()

        # parse this chunk
        cur = None
        cur_results = []
        cur_out = []
        for line in r.stdout.splitlines():
            m = MARK_START.search(line)
            if m:
                cur = int(m.group(1))
                cur_results = []
                cur_out = []
                continue
            m = MARK_EXIT.search(line)
            if m and cur is not None and int(m.group(1)) == cur:
                code = int(m.group(2))
                name = dev_cmds[cur][0]
                status = classify(code, cur_results, code in TIMEOUT_EXITS)
                outcomes[name] = {
                    'status': status,
                    'exit': code,
                    'subtests': {k: v for r_ in cur_results if 'subtest' in r_
                                 for k, v in r_['subtest'].items()},
                    'tail': b'\n'.join(cur_out[-8:]).decode(errors='replace')
                            if status in ('crash', 'timeout', 'fail', 'notrun') else '',
                }
                cur = None
                continue
            if cur is not None:
                cur_out.append(line)
                m = RESULT_LINE.search(line)
                if m:
                    try:
                        cur_results.append(json.loads(m.group(1)))
                    except json.JSONDecodeError:
                        pass
        # tests whose markers never appeared (adb drop, device reboot)
        for j, (name, _) in enumerate(chunk):
            outcomes.setdefault(dev_cmds[idx_base + j][0], {
                'status': 'missing', 'exit': -1, 'subtests': {}, 'tail': ''})
        idx_base += len(chunk)
        done = idx_base
        print(f'  chunk {ci + 1}/{len(chunks)} done ({done}/{len(dev_cmds)}, '
              f'{time.time() - t0:.0f}s elapsed)')
    raw_log.close()

    print('[4/4] writing results')
    counts = {}
    for name, o in outcomes.items():
        counts[o['status']] = counts.get(o['status'], 0) + 1
    result_doc = {
        'backend': args.backend,
        'use_angle': args.use_angle,
        'device_dir': dev,
        'timeout': args.timeout,
        'elapsed_sec': round(time.time() - t0, 1),
        'totals': counts,
        'tests': outcomes,
    }
    (args.out / 'results.json').write_text(json.dumps(result_doc, indent=1, sort_keys=True))
    lines = [f"backend: {args.backend}  tests: {len(outcomes)}  elapsed: {result_doc['elapsed_sec']}s"]
    lines.append('totals: ' + ', '.join(f'{k}={v}' for k, v in sorted(counts.items())))
    for status in ('crash', 'timeout', 'fail', 'missing', 'notrun', 'warn'):
        bad = sorted(n for n, o in outcomes.items() if o['status'] == status)
        if bad:
            lines.append(f'\n== {status} ({len(bad)}):')
            lines.extend(f'  {n}' for n in bad)
    (args.out / 'summary.txt').write_text('\n'.join(lines) + '\n')
    print('\n'.join(lines[:2]))
    print(f"results: {args.out / 'results.json'}")
    return 0


if __name__ == '__main__':
    sys.exit(main())
