#!/usr/bin/env python
"""Build MobileGL's Windows WGL shim and run Khronos OpenGL CTS suites.

The pipeline intentionally keeps the build, runtime, results, and reports in an
explicit work root.  Each run is keyed by the hashes of glcts.exe, opengl32.dll,
and (for DirectGLES) the ANGLE runtime, so resuming can never silently combine
results from different binaries.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import struct
import subprocess
import sys
from datetime import datetime, timezone
from typing import Iterable, Mapping, Optional, Sequence


SUPPORTED_VERSIONS = ("30", "31", "32", "33", "40", "41", "42", "43", "44", "45", "46")
SUPPORTED_BACKENDS = ("DirectGLES", "DirectVulkan")
ANGLE_REQUIRED_DLLS = ("libEGL.dll", "libGLESv2.dll", "d3dcompiler_47.dll")
ANGLE_OPTIONAL_DLLS = ("dxcompiler.dll", "dxil.dll")
PE_MACHINE_AMD64 = 0x8664
TRACKED_ENVIRONMENT_NAMES = frozenset({"PATH"})
TRACKED_ENVIRONMENT_PREFIXES = (
    "MOBILEGL_",
    "LIBGL_",
    "VK_",
    "ANGLE_",
    "EGL_",
    "D3D_",
    "DXVK_",
)
CONTROLLED_ENVIRONMENT_NAMES = frozenset(
    {"MOBILEGL_BACKEND_TYPE", "MOBILEGL_LOG_FILE_PATH"}
)

DEFAULT_DEQP_ARGS = (
    "--deqp-gl-context-type=wgl",
    "--deqp-surface-type=fbo",
    "--deqp-gl-config-name=rgba8888d24s8",
    # Height <= 0 is DONT_CARE, which FboRenderContext resolves to
    # GL_MAX_RENDERBUFFER_SIZE - a 64x16384 surface. Pin both.
    "--deqp-surface-width=64",
    "--deqp-surface-height=64",
    "--deqp-base-seed=3",
    "--deqp-visibility=hidden",
    "--deqp-watchdog=enable",
    "--deqp-crashhandler=enable",
)

SESSION_VENDOR = re.compile(r'^#sessionInfo vendor "([^"]*)"', re.MULTILINE)
SESSION_RENDERER = re.compile(r'^#sessionInfo renderer "([^"]*)"', re.MULTILINE)
SESSION_COMMAND_LINE = re.compile(r'^#sessionInfo commandLineParameters "([^"]*)"', re.MULTILINE)
TARGET_GL_VERSION = re.compile(r"Target OpenGL Version:\s*(\d+)\.(\d+)")


class PipelineError(RuntimeError):
    """A configuration, build, or identity error."""


def repository_root() -> Path:
    return Path(__file__).resolve().parents[3]


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def normalize_version(value: str) -> str:
    normalized = value.strip().lower().removeprefix("gl").replace(".", "")
    if normalized not in SUPPORTED_VERSIONS:
        supported = ", ".join(f"gl{version}" for version in SUPPORTED_VERSIONS)
        raise argparse.ArgumentTypeError(f"unsupported GL suite {value!r}; choose one of: {supported}")
    return normalized


def gl_version_tuple(version: str) -> tuple[int, int]:
    return int(version[0]), int(version[1])


def parse_assignment(value: str) -> tuple[str, str]:
    name, separator, setting = value.partition("=")
    if not separator or not name or "\x00" in value:
        raise argparse.ArgumentTypeError(f"expected NAME=VALUE, got {value!r}")
    if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
        raise argparse.ArgumentTypeError(f"invalid environment variable name {name!r}")
    return name, setting


def canonicalize_windows_environment(
    items: Iterable[tuple[str, str]],
) -> dict[str, str]:
    """Canonicalize environment keys using Windows' case-insensitive rules."""

    result: dict[str, str] = {}
    for name, value in items:
        result[name.upper()] = value
    return result


def tracked_run_environment(
    inherited: Mapping[str, str], overrides: Mapping[str, str]
) -> tuple[dict[str, str], dict[str, str]]:
    """Return tracked ambient values and the effective values used for identity."""

    canonical_inherited = canonicalize_windows_environment(inherited.items())
    ambient = {
        name: value
        for name, value in canonical_inherited.items()
        if name in TRACKED_ENVIRONMENT_NAMES
        or name.startswith(TRACKED_ENVIRONMENT_PREFIXES)
    }
    effective = dict(ambient)
    effective.update(canonicalize_windows_environment(overrides.items()))
    return dict(sorted(ambient.items())), dict(sorted(effective.items()))


def command_text(command: Sequence[object]) -> str:
    return subprocess.list2cmdline([str(part) for part in command])


def run_command(
    command: Sequence[object],
    *,
    cwd: Optional[Path] = None,
    env: Optional[Mapping[str, str]] = None,
    check: bool = True,
    capture: bool = False,
) -> subprocess.CompletedProcess[str]:
    rendered = command_text(command)
    location = f" (cwd={cwd})" if cwd else ""
    print(f"[wgl_glcts_pipeline] $ {rendered}{location}", flush=True)
    completed = subprocess.run(
        [str(part) for part in command],
        cwd=str(cwd) if cwd else None,
        env=dict(env) if env else None,
        text=True,
        capture_output=capture,
        check=False,
    )
    if check and completed.returncode != 0:
        detail = ""
        if capture:
            detail = f"\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
        raise PipelineError(f"command failed with exit code {completed.returncode}: {rendered}{detail}")
    return completed


def require_file(path: Path, label: str) -> Path:
    resolved = path.expanduser().resolve()
    if not resolved.is_file():
        raise PipelineError(f"{label} does not exist or is not a file: {resolved}")
    return resolved


def require_directory(path: Path, label: str) -> Path:
    resolved = path.expanduser().resolve()
    if not resolved.is_dir():
        raise PipelineError(f"{label} does not exist or is not a directory: {resolved}")
    return resolved


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for block in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def sha256_directory(path: Path) -> str:
    digest = hashlib.sha256()
    files = sorted(
        (candidate for candidate in path.rglob("*") if candidate.is_file()),
        key=lambda candidate: candidate.relative_to(path).as_posix(),
    )
    if not files:
        raise PipelineError(f"directory contains no files to fingerprint: {path}")
    for candidate in files:
        relative = candidate.relative_to(path).as_posix()
        digest.update(relative.encode("utf-8"))
        digest.update(b"\0")
        digest.update(sha256_file(candidate).encode("ascii"))
        digest.update(b"\n")
    return digest.hexdigest()


def pe_machine(path: Path) -> int:
    with path.open("rb") as stream:
        if stream.read(2) != b"MZ":
            raise PipelineError(f"not a PE executable: {path}")
        stream.seek(0x3C)
        offset_bytes = stream.read(4)
        if len(offset_bytes) != 4:
            raise PipelineError(f"truncated PE header: {path}")
        pe_offset = struct.unpack("<I", offset_bytes)[0]
        stream.seek(pe_offset)
        if stream.read(4) != b"PE\0\0":
            raise PipelineError(f"invalid PE signature: {path}")
        machine_bytes = stream.read(2)
        if len(machine_bytes) != 2:
            raise PipelineError(f"truncated PE COFF header: {path}")
        return struct.unpack("<H", machine_bytes)[0]


def require_x64_pe(path: Path, label: str) -> None:
    machine = pe_machine(path)
    if machine != PE_MACHINE_AMD64:
        raise PipelineError(f"{label} must be an x64 PE (machine 0x8664), got 0x{machine:04x}: {path}")


def git_snapshot(path: Path) -> dict[str, object]:
    snapshot: dict[str, object] = {"path": str(path)}
    try:
        head = run_command(
            ["git", "-C", path, "rev-parse", "HEAD"], check=True, capture=True
        ).stdout.strip()
        status = run_command(
            ["git", "-C", path, "status", "--porcelain"], check=True, capture=True
        ).stdout
        snapshot.update({"head": head, "dirty": bool(status.strip())})
    except (OSError, PipelineError):
        snapshot.update({"head": None, "dirty": None})
    return snapshot


def generator_arguments(generator: str, architecture: str) -> list[str]:
    arguments = ["-G", generator]
    if generator.lower().startswith("visual studio"):
        arguments.extend(["-A", architecture])
    return arguments


def mobilegl_configure_command(
    repo_root: Path,
    build_dir: Path,
    generator: str,
    architecture: str,
    extra: Iterable[str],
) -> list[str]:
    return [
        "cmake",
        "-S",
        str(repo_root),
        "-B",
        str(build_dir),
        *generator_arguments(generator, architecture),
        "-DMOBILEGL_BUILD_TEST=OFF",
        "-DMOBILEGL_BUILD_BENCHMARK=OFF",
        "-DMOBILEGL_BUILD_TRACE_REPLAY=OFF",
        "-DMOBILEGL_ENABLE_TRACY=OFF",
        "-DMOBILEGL_FORCE_RELEASE_OPT=ON",
        *extra,
    ]


def cts_configure_command(
    cts_source: Path,
    build_dir: Path,
    generator: str,
    architecture: str,
    extra: Iterable[str],
) -> list[str]:
    return [
        "cmake",
        "-S",
        str(cts_source),
        "-B",
        str(build_dir),
        *generator_arguments(generator, architecture),
        "-DDEQP_TARGET=default",
        "-DDEQP_SUPPORT_DRM=OFF",
        *extra,
    ]


def build_command(build_dir: Path, configuration: str, target: str, jobs: int) -> list[str]:
    command = ["cmake", "--build", str(build_dir), "--config", configuration, "--target", target]
    if jobs > 0:
        command.extend(["--parallel", str(jobs)])
    return command


def verify_mobilegl_sources(repo_root: Path) -> None:
    required = (
        repo_root / "CMakeLists.txt",
        repo_root / "MobileGL" / "MG_Impl" / "WGLImpl" / "WGLImpl.cpp",
        repo_root / "3rdparty" / "glslang" / "CMakeLists.txt",
        repo_root / "3rdparty" / "SPIRV-Cross" / "CMakeLists.txt",
        repo_root / "3rdparty" / "Vulkan-Headers" / "CMakeLists.txt",
    )
    missing = [str(path) for path in required if not path.is_file()]
    if missing:
        raise PipelineError(
            "MobileGL source/submodules are incomplete:\n  "
            + "\n  ".join(missing)
            + f"\nRun: git -C {repo_root} submodule update --init --recursive"
        )


def verify_cts_sources(cts_source: Path) -> None:
    required = (
        cts_source / "CMakeLists.txt",
        cts_source / "external" / "openglcts" / "CMakeLists.txt",
    )
    missing = [str(path) for path in required if not path.is_file()]
    if missing:
        raise PipelineError(
            "VK-GL-CTS source/external packages are incomplete:\n  "
            + "\n  ".join(missing)
            + f"\nRun: {sys.executable} {cts_source / 'external' / 'fetch_sources.py'}"
        )


def discover_mobilegl_dll(build_dir: Path, configuration: str) -> Path:
    preferred = (
        build_dir / configuration / "opengl32.dll",
        build_dir / "MobileGL" / configuration / "opengl32.dll",
        build_dir / "opengl32.dll",
    )
    for candidate in preferred:
        if candidate.is_file():
            return candidate.resolve()
    candidates = sorted({path.resolve() for path in build_dir.rglob("opengl32.dll") if path.is_file()})
    if len(candidates) == 1:
        return candidates[0]
    if not candidates:
        raise PipelineError(f"MobileGL build produced no opengl32.dll under {build_dir}")
    raise PipelineError("multiple opengl32.dll candidates; pass --mobilegl-dll explicitly:\n  " + "\n  ".join(map(str, candidates)))


def discover_glcts_exe(build_dir: Path, configuration: str) -> Path:
    preferred = (
        build_dir / "external" / "openglcts" / "modules" / configuration / "glcts.exe",
        build_dir / "external" / "openglcts" / "modules" / "glcts.exe",
    )
    for candidate in preferred:
        if candidate.is_file():
            return candidate.resolve()
    candidates = sorted({path.resolve() for path in build_dir.rglob("glcts.exe") if path.is_file()})
    if len(candidates) == 1:
        return candidates[0]
    if not candidates:
        raise PipelineError(f"CTS build produced no glcts.exe under {build_dir}")
    raise PipelineError("multiple glcts.exe candidates; pass --glcts-exe explicitly:\n  " + "\n  ".join(map(str, candidates)))


def default_cts_modules_dir(cts_build_dir: Path) -> Path:
    return cts_build_dir / "external" / "openglcts" / "modules"


def find_caselist_root(cts_modules_dir: Path, cts_source: Path) -> Path:
    relative = Path("gl_cts/data/mustpass/gl/khronos_mustpass/main")
    candidates = (cts_modules_dir / relative, cts_source / "external" / "openglcts" / "modules" / relative)
    for candidate in candidates:
        if candidate.is_dir():
            return candidate.resolve()
    raise PipelineError("Khronos GL mustpass directory was not found; checked:\n  " + "\n  ".join(map(str, candidates)))


def caselist_for(caselist_root: Path, version: str) -> Path:
    return require_file(caselist_root / f"gl{version}-main.txt", f"GL{version} mustpass caselist")


def runtime_source_files(
    glcts_exe: Path,
    mobilegl_dll: Path,
    backends: Sequence[str],
    angle_dir: Optional[Path],
) -> dict[str, Path]:
    files = {"glcts.exe": glcts_exe, "opengl32.dll": mobilegl_dll}
    if "DirectGLES" in backends:
        if angle_dir is None:
            raise PipelineError("--angle-dir is required when DirectGLES is selected")
        angle_dir = require_directory(angle_dir, "ANGLE runtime directory")
        for name in ANGLE_REQUIRED_DLLS:
            files[name] = require_file(angle_dir / name, f"ANGLE {name}")
        for name in ANGLE_OPTIONAL_DLLS:
            candidate = angle_dir / name
            if candidate.is_file():
                files[name] = candidate.resolve()
    return files


def runtime_fingerprint(files: Mapping[str, Path]) -> tuple[str, dict[str, str]]:
    hashes = {name: sha256_file(path) for name, path in sorted(files.items())}
    digest = hashlib.sha256()
    for name, file_hash in hashes.items():
        digest.update(f"{name}\0{file_hash}\n".encode("utf-8"))
    return digest.hexdigest(), hashes


def run_fingerprint(
    runtime_hash: str,
    cts_data_hash: str,
    tool_hashes: Mapping[str, str],
    caselist_hashes: Mapping[str, str],
    deqp_args: Sequence[str],
    environment: Mapping[str, str],
    result_semantics: Optional[Mapping[str, object]] = None,
) -> str:
    identity = {
        "version": 2,
        "runtime_fingerprint": runtime_hash,
        "cts_data_sha256": cts_data_hash,
        "tool_hashes": dict(sorted(tool_hashes.items())),
        "caselist_hashes": dict(sorted(caselist_hashes.items())),
        "deqp_args": list(deqp_args),
        "environment": dict(sorted(environment.items())),
        "result_semantics": dict(sorted((result_semantics or {}).items())),
    }
    return hashlib.sha256(
        json.dumps(identity, sort_keys=True, separators=(",", ":")).encode("utf-8")
    ).hexdigest()


def assemble_runtime(
    work_root: Path,
    sources: Mapping[str, Path],
    fingerprint: str,
    hashes: Mapping[str, str],
) -> Path:
    runtime_dir = work_root / "runtime" / fingerprint[:16]
    runtime_dir.mkdir(parents=True, exist_ok=True)
    for name, source in sources.items():
        require_x64_pe(source, name)
        destination = runtime_dir / name
        if source.resolve() != destination.resolve():
            shutil.copy2(source, destination)
        if sha256_file(destination) != hashes[name]:
            raise PipelineError(f"runtime copy hash mismatch: {destination}")
    manifest = {
        "version": 1,
        "created_utc": utc_now(),
        "fingerprint": fingerprint,
        "files": {
            name: {"source": str(source), "sha256": hashes[name]}
            for name, source in sorted(sources.items())
        },
    }
    write_json(runtime_dir / "manifest.json", manifest)
    return runtime_dir


def write_json(path: Path, value: object) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_name(path.name + ".tmp")
    temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    os.replace(temporary, path)


def read_cases(caselist: Path) -> list[str]:
    cases: list[str] = []
    for raw_line in caselist.read_text(encoding="utf-8-sig").splitlines():
        line = raw_line.strip()
        if line and not line.startswith("#"):
            cases.append(line)
    if not cases:
        raise PipelineError(f"caselist contains no cases: {caselist}")
    return cases


def choose_preflight_case(caselist: Path) -> str:
    cases = read_cases(caselist)
    preferred_suffixes = (
        ".buffer_objects.gen_buffers",
        ".CommonBugs.CommonBug_GetProgramivActiveUniformBlockMaxNameLength",
    )
    for suffix in preferred_suffixes:
        for case in cases:
            if case.endswith(suffix):
                return case
    return cases[0]


def runner_command(
    runner: Path,
    runtime_exe: Path,
    workdir: Path,
    caselist: Path,
    outdir: Path,
    backend: str,
    idle_timeout: float,
    max_round_seconds: float,
    max_rounds: int,
    environment: Mapping[str, str],
    deqp_args: Sequence[str],
    run_identity: Optional[str] = None,
) -> list[str]:
    command = [
        sys.executable,
        str(runner),
        "--exe",
        str(runtime_exe),
        "--workdir",
        str(workdir),
        "--caselist",
        str(caselist),
        "--outdir",
        str(outdir),
        "--backend",
        backend,
        "--idle-timeout",
        str(idle_timeout),
        "--max-round-seconds",
        str(max_round_seconds),
        "--max-rounds",
        str(max_rounds),
    ]
    if run_identity is not None:
        command.extend(["--run-identity", run_identity])
    for name, value in sorted(environment.items()):
        command.extend(["--env", f"{name}={value}"])
    command.extend(f"--deqp-arg={argument}" for argument in deqp_args)
    return command


def parse_preflight_identity(
    qpa_files: Sequence[Path],
    mobilegl_log: Path,
    backend: str,
    minimum_version: tuple[int, int],
) -> dict[str, object]:
    if not qpa_files:
        raise PipelineError(f"{backend} preflight produced no QPA file")
    qpa_text = "\n".join(path.read_text(encoding="utf-8", errors="replace") for path in qpa_files)
    vendors = SESSION_VENDOR.findall(qpa_text)
    renderers = SESSION_RENDERER.findall(qpa_text)
    command_lines = SESSION_COMMAND_LINE.findall(qpa_text)
    if not vendors or "MobileGL" not in vendors[-1]:
        raise PipelineError(f"{backend} preflight did not load MobileGL (vendor={vendors[-1] if vendors else None!r})")
    expected_renderer = "Espryt" if backend == "DirectGLES" else "Magma"
    if not renderers or expected_renderer not in renderers[-1]:
        raise PipelineError(
            f"{backend} preflight renderer mismatch: expected {expected_renderer!r}, "
            f"got {renderers[-1] if renderers else None!r}"
        )
    if not command_lines or "--deqp-gl-context-type=wgl" not in command_lines[-1]:
        raise PipelineError(f"{backend} preflight did not record a WGL context")
    if not mobilegl_log.is_file():
        raise PipelineError(f"{backend} preflight did not create MobileGL log: {mobilegl_log}")
    log_text = mobilegl_log.read_text(encoding="utf-8", errors="replace")
    versions = [(int(major), int(minor)) for major, minor in TARGET_GL_VERSION.findall(log_text)]
    if not versions:
        raise PipelineError(f"{backend} preflight log contains no target OpenGL version")
    actual_version = versions[-1]
    if actual_version < minimum_version:
        raise PipelineError(
            f"{backend} reports GL {actual_version[0]}.{actual_version[1]}, "
            f"but selected suites require at least {minimum_version[0]}.{minimum_version[1]}"
        )
    return {
        "backend": backend,
        "vendor": vendors[-1],
        "renderer": renderers[-1],
        "target_gl_version": f"{actual_version[0]}.{actual_version[1]}",
        "qpa_files": [str(path) for path in qpa_files],
        "mobilegl_log": str(mobilegl_log),
    }


def run_preflight(
    *,
    runner: Path,
    runtime_exe: Path,
    cts_modules_dir: Path,
    caselist: Path,
    preflight_root: Path,
    backend: str,
    idle_timeout: float,
    environment: Mapping[str, str],
    deqp_args: Sequence[str],
    minimum_version: tuple[int, int],
    run_identity: str,
) -> dict[str, object]:
    outdir = preflight_root / backend.lower()
    outdir.mkdir(parents=True, exist_ok=True)
    case_file = outdir / "case.txt"
    case_file.write_text(choose_preflight_case(caselist) + "\n", encoding="utf-8")
    log_path = outdir / "mobilegl.log"
    child_environment = dict(environment)
    child_environment["MOBILEGL_LOG_FILE_PATH"] = str(log_path)
    command = runner_command(
        runner,
        runtime_exe,
        cts_modules_dir,
        case_file,
        outdir,
        backend,
        min(idle_timeout, 120.0) if idle_timeout > 0 else 120.0,
        180.0,
        1,
        child_environment,
        deqp_args,
        run_identity,
    )
    # A developing driver may fail the chosen case or terminate during deinit.
    # Identity is the gate: the QPA and MobileGL log must prove which WGL driver ran.
    run_command(command, cwd=repository_root(), check=False)
    identity = parse_preflight_identity(sorted(outdir.glob("chunk*.qpa")), log_path, backend, minimum_version)
    print(
        f"[wgl_glcts_pipeline] preflight {backend}: {identity['renderer']} | "
        f"GL {identity['target_gl_version']}"
    )
    return identity


def report_command(
    reporter: Path,
    suites: Sequence[tuple[str, str, Path, Path]],
    markdown: Path,
    json_out: Path,
    allow_incomplete: bool,
    expected_run_identity: Optional[str] = None,
) -> list[str]:
    command = [sys.executable, str(reporter)]
    for backend, label, caselist, results in suites:
        command.extend(["--suite", backend, label, str(caselist), str(results)])
    command.extend(["--markdown", str(markdown), "--json", str(json_out)])
    if expected_run_identity is not None:
        command.extend(["--expected-run-identity", expected_run_identity])
    if allow_incomplete:
        command.append("--allow-incomplete")
    return command


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Build MobileGL WGL, assemble a local glcts runtime, run GL30-GL46, and report results."
    )
    parser.add_argument("--repo-root", type=Path, default=repository_root(), help="MobileGL worktree root")
    parser.add_argument("--cts-source", type=Path, required=True, help="VK-GL-CTS source checkout")
    parser.add_argument("--work-root", type=Path, required=True, help="build/result root (kept outside source)")
    parser.add_argument("--angle-dir", type=Path, help="x64 ANGLE directory for DirectGLES")
    parser.add_argument("--backends", nargs="+", choices=SUPPORTED_BACKENDS, default=list(SUPPORTED_BACKENDS))
    parser.add_argument("--versions", nargs="+", type=normalize_version, default=list(SUPPORTED_VERSIONS))
    parser.add_argument("--configuration", default="Release")
    parser.add_argument("--generator", default="Visual Studio 17 2022")
    parser.add_argument("--architecture", default="x64")
    parser.add_argument("--jobs", type=int, default=max(1, os.cpu_count() or 1))
    parser.add_argument("--mobilegl-build-dir", type=Path)
    parser.add_argument("--cts-build-dir", type=Path)
    parser.add_argument("--mobilegl-dll", type=Path, help="reuse an existing MobileGL/opengl32 DLL")
    parser.add_argument("--glcts-exe", type=Path, help="reuse an existing glcts.exe")
    parser.add_argument("--cts-modules-dir", type=Path, help="glcts working directory containing gl_cts data")
    parser.add_argument("--skip-mobilegl-build", action="store_true")
    parser.add_argument("--skip-cts-build", action="store_true")
    parser.add_argument("--skip-preflight", action="store_true")
    parser.add_argument("--skip-run", action="store_true")
    parser.add_argument("--skip-report", action="store_true")
    parser.add_argument("--allow-incomplete-report", action="store_true")
    parser.add_argument("--continue-on-suite-error", action="store_true")
    parser.add_argument("--idle-timeout", type=float, default=300.0)
    parser.add_argument("--max-round-seconds", type=float, default=0.0)
    parser.add_argument("--max-rounds", type=int, default=10000)
    parser.add_argument("--env", action="append", type=parse_assignment, default=[], metavar="NAME=VALUE")
    parser.add_argument(
        "--deqp-arg", action="append", default=[], metavar="ARG", help="extra glcts option; use --deqp-arg=--x=y"
    )
    parser.add_argument(
        "--mobilegl-cmake-arg", action="append", default=[], metavar="ARG", help="extra MobileGL configure option"
    )
    parser.add_argument("--cts-cmake-arg", action="append", default=[], metavar="ARG", help="extra CTS configure option")
    return parser


def execute(args: argparse.Namespace) -> int:
    if os.name != "nt":
        raise PipelineError("this pipeline builds and exercises the Windows WGL target and must run on Windows")
    if shutil.which("cmake") is None and (not args.skip_mobilegl_build or not args.skip_cts_build):
        raise PipelineError("cmake was not found on PATH")
    if args.jobs < 0 or args.max_rounds <= 0:
        raise PipelineError("--jobs must be >= 0 and --max-rounds must be > 0")

    repo_root = require_directory(args.repo_root, "MobileGL worktree")
    cts_source = require_directory(args.cts_source, "VK-GL-CTS checkout")
    work_root = args.work_root.expanduser().resolve()
    work_root.mkdir(parents=True, exist_ok=True)
    versions = list(dict.fromkeys(args.versions))
    backends = list(dict.fromkeys(args.backends))
    minimum_version = max(gl_version_tuple(version) for version in versions)
    extra_environment = canonicalize_windows_environment(args.env)
    reserved_environment = CONTROLLED_ENVIRONMENT_NAMES & set(extra_environment)
    if reserved_environment:
        raise PipelineError("the pipeline controls these environment variables: " + ", ".join(sorted(reserved_environment)))

    configuration = args.configuration
    mobilegl_build_dir = (args.mobilegl_build_dir or work_root / f"mobilegl-build-{configuration.lower()}").resolve()
    cts_build_dir = (args.cts_build_dir or work_root / f"cts-build-wgl-{configuration.lower()}").resolve()

    if not args.skip_mobilegl_build:
        verify_mobilegl_sources(repo_root)
        mobilegl_build_dir.mkdir(parents=True, exist_ok=True)
        run_command(
            mobilegl_configure_command(
                repo_root, mobilegl_build_dir, args.generator, args.architecture, args.mobilegl_cmake_arg
            ),
            cwd=repo_root,
        )
        run_command(build_command(mobilegl_build_dir, configuration, "MobileGL", args.jobs), cwd=repo_root)
    mobilegl_dll = (
        require_file(args.mobilegl_dll, "MobileGL DLL")
        if args.mobilegl_dll
        else discover_mobilegl_dll(mobilegl_build_dir, configuration)
    )

    if not args.skip_cts_build:
        verify_cts_sources(cts_source)
        cts_build_dir.mkdir(parents=True, exist_ok=True)
        run_command(
            cts_configure_command(cts_source, cts_build_dir, args.generator, args.architecture, args.cts_cmake_arg),
            cwd=cts_source,
        )
        run_command(build_command(cts_build_dir, configuration, "glcts", args.jobs), cwd=cts_source)
    glcts_exe = (
        require_file(args.glcts_exe, "glcts executable")
        if args.glcts_exe
        else discover_glcts_exe(cts_build_dir, configuration)
    )
    cts_modules_dir = require_directory(
        args.cts_modules_dir or default_cts_modules_dir(cts_build_dir), "CTS modules/working directory"
    )
    cts_data_dir = require_directory(cts_modules_dir / "gl_cts" / "data", "CTS gl_cts data directory")
    cts_data_hash = sha256_directory(cts_data_dir)
    caselist_root = find_caselist_root(cts_modules_dir, cts_source)
    caselists = {version: caselist_for(caselist_root, version) for version in versions}

    runtime_sources = runtime_source_files(glcts_exe, mobilegl_dll, backends, args.angle_dir)
    fingerprint, hashes = runtime_fingerprint(runtime_sources)
    runtime_dir = assemble_runtime(work_root, runtime_sources, fingerprint, hashes)
    runtime_exe = runtime_dir / "glcts.exe"
    runner = require_file(repo_root / "tools" / "cts" / "scripts" / "run_cts_windows.py", "Windows CTS runner")
    reporter = require_file(repo_root / "tools" / "cts" / "scripts" / "cts_multi_report.py", "CTS reporter")
    matrix_reporter = require_file(
        repo_root / "tools" / "cts" / "scripts" / "cts_matrix_report.py", "CTS matrix reporter"
    )
    qpa_reporter = require_file(repo_root / "tools" / "cts" / "scripts" / "qpa_report.py", "QPA parser")
    tool_hashes = {
        "wgl_glcts_pipeline.py": sha256_file(Path(__file__).resolve()),
        "run_cts_windows.py": sha256_file(runner),
        "cts_multi_report.py": sha256_file(reporter),
        "cts_matrix_report.py": sha256_file(matrix_reporter),
        "qpa_report.py": sha256_file(qpa_reporter),
    }
    deqp_args = [*DEFAULT_DEQP_ARGS, *args.deqp_arg]
    caselist_hashes = {version: sha256_file(path) for version, path in caselists.items()}
    ambient_environment, identity_environment = tracked_run_environment(
        os.environ, extra_environment
    )
    result_semantics = {
        "idle_timeout_seconds": args.idle_timeout,
        "max_round_seconds": args.max_round_seconds,
    }
    execution_settings = {
        **result_semantics,
        "max_rounds": args.max_rounds,
        "continue_on_suite_error": args.continue_on_suite_error,
    }
    execution_fingerprint = run_fingerprint(
        fingerprint,
        cts_data_hash,
        tool_hashes,
        caselist_hashes,
        deqp_args,
        identity_environment,
        result_semantics,
    )
    run_root = work_root / "runs" / execution_fingerprint[:16]
    report_root = run_root / "reports"

    manifest = {
        "version": 1,
        "created_utc": utc_now(),
        "run_fingerprint": execution_fingerprint,
        "runtime_fingerprint": fingerprint,
        "runtime_hashes": hashes,
        "tool_hashes": tool_hashes,
        "cts_data": {"path": str(cts_data_dir), "sha256": cts_data_hash},
        "runtime_dir": str(runtime_dir),
        "mobilegl": git_snapshot(repo_root),
        "vk_gl_cts": git_snapshot(cts_source),
        "configuration": configuration,
        "generator": args.generator,
        "architecture": args.architecture,
        "backends": backends,
        "versions": versions,
        "caselists": {version: {"path": str(path), "sha256": caselist_hashes[version]} for version, path in caselists.items()},
        "deqp_args": deqp_args,
        "environment_overrides": extra_environment,
        "ambient_environment": ambient_environment,
        "identity_environment": identity_environment,
        "result_semantics": result_semantics,
        "execution_settings": execution_settings,
    }
    write_json(run_root / "manifest.json", manifest)
    print(f"[wgl_glcts_pipeline] runtime fingerprint: {fingerprint}")
    print(f"[wgl_glcts_pipeline] run fingerprint: {execution_fingerprint}")
    print(f"[wgl_glcts_pipeline] run root: {run_root}")

    identities: list[dict[str, object]] = []
    if not args.skip_preflight:
        first_caselist = caselists[versions[0]]
        preflight_root = run_root / "preflight"
        for backend in backends:
            identities.append(
                run_preflight(
                    runner=runner,
                    runtime_exe=runtime_exe,
                    cts_modules_dir=cts_modules_dir,
                    caselist=first_caselist,
                    preflight_root=preflight_root,
                    backend=backend,
                    idle_timeout=args.idle_timeout,
                    environment=extra_environment,
                    deqp_args=deqp_args,
                    minimum_version=minimum_version,
                    run_identity=execution_fingerprint,
                )
            )
        manifest["preflight"] = identities
        write_json(run_root / "manifest.json", manifest)

    suites: list[tuple[str, str, Path, Path]] = []
    suite_errors: list[dict[str, object]] = []
    for backend in backends:
        for version in versions:
            label = f"gl{version}"
            result_dir = run_root / "results" / backend.lower() / label
            suites.append((backend, label, caselists[version], result_dir))
            if args.skip_run:
                continue
            result_dir.mkdir(parents=True, exist_ok=True)
            child_environment = dict(extra_environment)
            child_environment["MOBILEGL_LOG_FILE_PATH"] = str(result_dir / "mobilegl.log")
            command = runner_command(
                runner,
                runtime_exe,
                cts_modules_dir,
                caselists[version],
                result_dir,
                backend,
                args.idle_timeout,
                args.max_round_seconds,
                args.max_rounds,
                child_environment,
                deqp_args,
                execution_fingerprint,
            )
            completed = run_command(command, cwd=repo_root, check=False)
            if completed.returncode != 0:
                suite_errors.append({"backend": backend, "suite": label, "returncode": completed.returncode})
                if not args.continue_on_suite_error:
                    break
        if suite_errors and not args.continue_on_suite_error:
            break

    report_returncode: Optional[int] = None
    if not args.skip_report:
        report_root.mkdir(parents=True, exist_ok=True)
        completed = run_command(
            report_command(
                reporter,
                suites,
                report_root / "gl-cts-summary.md",
                report_root / "gl-cts-summary.json",
                args.allow_incomplete_report or bool(suite_errors),
                execution_fingerprint,
            ),
            cwd=repo_root,
            check=False,
        )
        report_returncode = completed.returncode

    manifest["suite_errors"] = suite_errors
    manifest["report_returncode"] = report_returncode
    manifest["finished_utc"] = utc_now()
    write_json(run_root / "manifest.json", manifest)
    if suite_errors:
        print(f"[wgl_glcts_pipeline] {len(suite_errors)} suite runner(s) incomplete; see manifest/report", file=sys.stderr)
        returncodes = {int(item["returncode"]) for item in suite_errors}
        if 130 in returncodes:
            return 130
        if 2 in returncodes:
            return 2
        if 3 in returncodes:
            return 3
        return 4
    if report_returncode is not None and report_returncode != 0:
        print(f"[wgl_glcts_pipeline] report validation failed with exit code {report_returncode}", file=sys.stderr)
        return report_returncode
    return 0


def main(argv: Optional[Sequence[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        return execute(args)
    except PipelineError as exc:
        print(f"[wgl_glcts_pipeline] ERROR: {exc}", file=sys.stderr)
        return 2
    except OSError as exc:
        print(f"[wgl_glcts_pipeline] ERROR: filesystem/process operation failed: {exc}", file=sys.stderr)
        return 2


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