#!/usr/bin/env python3
"""Verify and identify the source chain used by the native OpenQ4 runtime."""

from __future__ import annotations

import argparse
import hashlib
import json
import pathlib
import re
import shutil
import subprocess
import tempfile


OPENQ4_PIN = "975e296b80b66f63b817d16ea78b53e0685c5c55"
OPENQ4_TREE = "bcdff22ce6682e16586f66b2f2df6ae0f3665a60"
OPENQ4_EXPORT_TREE = "3a4d99cbd63c4ed29c47d5d9708a55e80447bb52"
GAMELIBS_PIN = "eb26e5f3a52358c58ee00bee5602575e5b057164"
GAMELIBS_TREE = "9035a51f4901dceb16449927e2d9ef36afee6d02"

OPENQ4_PATCHES = (
    "0001-fix-bot-client-slot-return.patch",
    "0002-load-combined-special-reachabilities.patch",
    "0004-full-state-server-demos.patch",
    "0005-port-d3wasm-aas-compiler.patch",
    "0007-static-gamelibs-and-web-runtime.patch",
    "0008-guard-mapload-net-verbose.patch",
    "0011-throttle-blocking-load-pacifier.patch",
    "0013-require-free-web-runtime-paks.patch",
    "0015-guard-partial-openal-shutdown.patch",
    "0016-buffer-browser-savegame-writes.patch",
    "0018-preserve-bot-userinfo-across-map-change.patch",
    "0020-bounded-replay-reliable-backpressure.patch",
    "0023-defer-websocket-messages-during-asyncify.patch",
    "0024-replace-replay-anchor-with-retail-decl.patch",
    "0025-disable-audio-in-dedicated-build.patch",
    "0028-flush-dedicated-stdout.patch",
    "0032-include-playerclip-in-aas-build.patch",
    "0035-refresh-tracemodel-material-after-decl-reload.patch",
)
GAMELIBS_PATCHES = (
    "0003-initialize-and-stabilize-bots.patch",
    "0006-enable-replay-repeater.patch",
    "0009-port-bots-to-mpgame.patch",
    "0017-guard-bot-entity-slots.patch",
    "0019-report-registered-bot-status.patch",
    "0021-full-state-replay-viewers.patch",
    "0022-skip-server-bot-ai-on-web-clients.patch",
    "0029-release-bot-resources-on-map-change.patch",
    "0031-use-bot-enemy-for-cover-target.patch",
    "0033-report-bot-navigation-status.patch",
    "0034-enter-bots-through-player-lifecycle.patch",
)


def run(*args: str | pathlib.Path, cwd: pathlib.Path | None = None) -> bytes:
    return subprocess.run(
        [str(value) for value in args],
        cwd=cwd,
        check=True,
        stdout=subprocess.PIPE,
    ).stdout


def sha256_bytes(value: bytes) -> str:
    return hashlib.sha256(value).hexdigest()


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


def patch_entries(root: pathlib.Path) -> list[dict[str, str]]:
    patch_root = root / "port/native/patches"
    entries = []
    for tree, names in (("openq4", OPENQ4_PATCHES), ("gamelibs", GAMELIBS_PATCHES)):
        for name in names:
            patch = patch_root / name
            if patch.is_symlink() or not patch.is_file():
                raise ValueError(f"native source patch is missing or is not regular: {patch}")
            entries.append({
                "tree": tree,
                "path": f"port/native/patches/{name}",
                "sha256": sha256_file(patch),
            })
    return entries


def verify_build_script_patch_lists(root: pathlib.Path) -> None:
    build_script = root / "tools/build-native.sh"
    if build_script.is_symlink() or not build_script.is_file():
        raise ValueError("native build script is missing or is not regular")
    source = build_script.read_text(encoding="utf-8")

    def names(variable: str) -> tuple[str, ...]:
        match = re.search(rf"^{variable}=\((.*?)^\)$", source, flags=re.MULTILINE | re.DOTALL)
        if not match:
            raise ValueError(f"native build script lacks {variable}")
        return tuple(re.findall(r"patches/(\d{4}-[^/\"']+\.patch)", match.group(1)))

    if names("OPENQ4_PATCH_FILES") != OPENQ4_PATCHES:
        raise ValueError("native OpenQ4 verifier patch list differs from tools/build-native.sh")
    if names("GAMELIBS_PATCH_FILES") != GAMELIBS_PATCHES:
        raise ValueError("native GameLib verifier patch list differs from tools/build-native.sh")


def patch_manifest_sha(entries: list[dict[str, str]]) -> str:
    payload = b"".join(
        f"{pathlib.PurePosixPath(entry['path']).name} {entry['sha256']}\n".encode("ascii")
        for entry in entries
    )
    return sha256_bytes(payload)


def marker(path: pathlib.Path) -> tuple[str, str, str]:
    marker_path = path / ".source-revision"
    if marker_path.is_symlink() or not marker_path.is_file():
        raise ValueError(f"source snapshot marker is missing or is not regular: {marker_path}")
    values: dict[str, str] = {}
    for line in marker_path.read_text(encoding="ascii").splitlines():
        key, separator, value = line.partition("=")
        if not separator or key in values:
            raise ValueError(f"invalid source snapshot marker line: {line!r}")
        values[key] = value
    commit = values.get("commit", "")
    tree = values.get("tree", "")
    export_tree = values.get("export-tree", tree)
    return commit, tree, export_tree


def materialize(
    source: pathlib.Path,
    destination: pathlib.Path,
    pin: str,
    tree: str,
    export_tree: str,
    label: str,
) -> None:
    if not source.is_dir():
        raise ValueError(f"missing {label} source input: {source}")
    if (source / ".git").exists():
        actual_pin = run("git", "-C", source, "rev-parse", "HEAD").decode().strip()
        actual_tree = run("git", "-C", source, "rev-parse", f"{pin}^{{tree}}").decode().strip()
        if actual_pin != pin or actual_tree != tree:
            raise ValueError(f"{label} Git source does not match its pinned commit/tree")
        run("git", "clone", "--quiet", "--no-checkout", source, destination)
        run("git", "-C", destination, "checkout", "--quiet", "--detach", pin)
        return

    actual_pin, actual_tree, actual_export_tree = marker(source)
    if (actual_pin, actual_tree, actual_export_tree) != (pin, tree, export_tree):
        raise ValueError(f"{label} snapshot marker does not match its pinned identity")
    if any(path.name == ".git" for path in source.rglob(".git")):
        raise ValueError(f"{label} snapshot contains nested Git metadata")

    shutil.copytree(source, destination, symlinks=True)
    (destination / ".source-revision").unlink()
    run("git", "init", "--quiet", cwd=destination)
    run("git", "config", "user.name", "OpenQ4 native source verifier", cwd=destination)
    run("git", "config", "user.email", "source-verifier@localhost.invalid", cwd=destination)
    run("git", "add", "--force", "--all", cwd=destination)
    staged_tree = run("git", "write-tree", cwd=destination).decode().strip()
    if staged_tree != export_tree:
        raise ValueError(
            f"{label} materialized snapshot tree mismatch: expected {export_tree}, got {staged_tree}"
        )
    run("git", "commit", "--quiet", "-m", f"Pinned {label} snapshot {pin}", cwd=destination)


def apply_patches(root: pathlib.Path, source: pathlib.Path, names: tuple[str, ...]) -> None:
    for name in names:
        patch = root / "port/native/patches" / name
        # Match tools/build-native.sh exactly. Some historical patches are valid
        # for POSIX patch but intentionally do not carry Git index metadata.
        run(
            "patch", "--batch", "--forward", "--no-backup-if-mismatch",
            f"--directory={source}", "--strip=1", f"--input={patch}",
        )


def patched_diff_sha(source: pathlib.Path, *, openq4: bool) -> str:
    run("git", "add", "-N", ".", cwd=source)
    args: list[str] = ["git", "diff", "--binary", "--no-ext-diff", "--", "."]
    if openq4:
        args.extend((
            ":(exclude)content/baseoq4/pak0",
            ":(exclude)content/baseoq4/pak0/**",
            ":(exclude)content/baseoq4/pak1",
            ":(exclude)content/baseoq4/pak1/**",
        ))
    return sha256_bytes(run(*args, cwd=source))


def verify(root: pathlib.Path) -> dict:
    root = root.resolve()
    verify_build_script_patch_lists(root)
    entries = patch_entries(root)
    with tempfile.TemporaryDirectory(prefix="q4-native-source-") as temporary:
        work = pathlib.Path(temporary)
        openq4 = work / "openq4"
        gamelibs = work / "openq4-game"
        materialize(
            root / "vendor/openq4", openq4,
            OPENQ4_PIN, OPENQ4_TREE, OPENQ4_EXPORT_TREE, "OpenQ4",
        )
        materialize(
            root / "vendor/openq4-game", gamelibs,
            GAMELIBS_PIN, GAMELIBS_TREE, GAMELIBS_TREE, "OpenQ4-GameLibs",
        )
        apply_patches(root, openq4, OPENQ4_PATCHES)
        apply_patches(root, gamelibs, GAMELIBS_PATCHES)
        run("git", "diff", "--check", cwd=openq4)
        run("git", "diff", "--check", cwd=gamelibs)
        posix_main = (openq4 / "src/sys/posix/posix_main.cpp").read_text(encoding="utf-8")
        if "fflush( stdout );" not in posix_main:
            raise ValueError("native OpenQ4 source lacks the dedicated stdout flush invariant")
        run(
            "python3",
            root / "tools/test-tracemodel-material-refresh.py",
            "--root", root,
            "--source-root", openq4,
        )
        return {
            "schema": 1,
            "openq4": OPENQ4_PIN,
            "gamelibs": GAMELIBS_PIN,
            "patchManifestSha256": patch_manifest_sha(entries),
            "openq4PatchedDiffSha256": patched_diff_sha(openq4, openq4=True),
            "gamelibsPatchedDiffSha256": patched_diff_sha(gamelibs, openq4=False),
            "patches": entries,
        }


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--root", type=pathlib.Path, required=True)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()
    try:
        identity = verify(args.root)
    except (OSError, subprocess.CalledProcessError, UnicodeError, ValueError) as error:
        raise SystemExit(f"native-source-identity: {error}") from error

    if args.json:
        print(json.dumps(identity, sort_keys=True))
        return
    print("OpenQ4 native source verification passed.")
    print(f"openq4={identity['openq4']}")
    print(f"gamelibs={identity['gamelibs']}")
    print(f"native_patch_manifest_sha256={identity['patchManifestSha256']}")
    print(f"native_openq4_patched_diff_sha256={identity['openq4PatchedDiffSha256']}")
    print(f"native_gamelibs_patched_diff_sha256={identity['gamelibsPatchedDiffSha256']}")


if __name__ == "__main__":
    main()
