#!/usr/bin/env bash
set -euo pipefail

ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="${ROOT}/public/source"
OUT_NAME="openq4-web-source.tar.xz"
OUT="${OUT_DIR}/${OUT_NAME}"
SUM_OUT="${OUT}.sha256"
OPENQ4_VENDOR="${ROOT}/vendor/openq4"
GAMELIBS_VENDOR="${ROOT}/vendor/openq4-game"
FREE_RUNTIME_SOURCE="${ROOT}/content-src/baseoq4"
FREE_RUNTIME_POLICY="${ROOT}/content-src/public-runtime.json"
FREE_RUNTIME_BUILDER="${ROOT}/tools/build-free-runtime.py"
OPENQ4_PIN=975e296b80b66f63b817d16ea78b53e0685c5c55
GAMELIBS_PIN=eb26e5f3a52358c58ee00bee5602575e5b057164
OPENQ4_TREE=bcdff22ce6682e16586f66b2f2df6ae0f3665a60
OPENQ4_EXPORT_TREE=3a4d99cbd63c4ed29c47d5d9708a55e80447bb52
GAMELIBS_TREE=9035a51f4901dceb16449927e2d9ef36afee6d02
SOURCE_TREE_AUDITOR="${ROOT}/tools/audit-source-offer-tree.py"
SOURCE_IDENTITY_TOOL="${ROOT}/tools/source-offer-identity.py"
NATIVE_SOURCE_IDENTITY_TOOL="${ROOT}/tools/native-source-identity.py"
OPENQ4_PATCH_FILES=(
    "${ROOT}/port/native/patches/0001-fix-bot-client-slot-return.patch"
    "${ROOT}/port/native/patches/0002-load-combined-special-reachabilities.patch"
    "${ROOT}/port/native/patches/0004-full-state-server-demos.patch"
    "${ROOT}/port/native/patches/0005-port-d3wasm-aas-compiler.patch"
    "${ROOT}/port/native/patches/0007-static-gamelibs-and-web-runtime.patch"
    "${ROOT}/port/native/patches/0008-guard-mapload-net-verbose.patch"
    "${ROOT}/port/native/patches/0010-web-background-download-fallback.patch"
    "${ROOT}/port/native/patches/0011-throttle-blocking-load-pacifier.patch"
    "${ROOT}/port/native/patches/0012-bind-sdl3-web-canvas.patch"
    "${ROOT}/port/native/patches/0013-require-free-web-runtime-paks.patch"
    "${ROOT}/port/native/patches/0014-cache-web-pk4-central-directory.patch"
    "${ROOT}/port/native/patches/0015-guard-partial-openal-shutdown.patch"
    "${ROOT}/port/native/patches/0016-buffer-browser-savegame-writes.patch"
    "${ROOT}/port/native/patches/0018-preserve-bot-userinfo-across-map-change.patch"
    "${ROOT}/port/native/patches/0020-bounded-replay-reliable-backpressure.patch"
    "${ROOT}/port/native/patches/0023-defer-websocket-messages-during-asyncify.patch"
    "${ROOT}/port/native/patches/0024-replace-replay-anchor-with-retail-decl.patch"
    "${ROOT}/port/native/patches/0025-disable-audio-in-dedicated-build.patch"
    "${ROOT}/port/native/patches/0026-disable-growable-arraybuffers.patch"
    "${ROOT}/port/native/patches/0027-disable-web-memory-lock-syscalls.patch"
    "${ROOT}/port/native/patches/0030-use-rgba8-web-light-textures.patch"
    "${ROOT}/port/native/patches/0032-include-playerclip-in-aas-build.patch"
    "${ROOT}/port/native/patches/0035-refresh-tracemodel-material-after-decl-reload.patch"
    "${ROOT}/port/native/patches/0036-preserve-browser-video-settings-after-reload.patch"
    "${ROOT}/port/native/patches/0037-normalize-browser-video-before-renderer-init.patch"
)
GAMELIBS_PATCH_FILES=(
    "${ROOT}/port/native/patches/0003-initialize-and-stabilize-bots.patch"
    "${ROOT}/port/native/patches/0006-enable-replay-repeater.patch"
    "${ROOT}/port/native/patches/0009-port-bots-to-mpgame.patch"
    "${ROOT}/port/native/patches/0017-guard-bot-entity-slots.patch"
    "${ROOT}/port/native/patches/0019-report-registered-bot-status.patch"
    "${ROOT}/port/native/patches/0021-full-state-replay-viewers.patch"
    "${ROOT}/port/native/patches/0022-skip-server-bot-ai-on-web-clients.patch"
    "${ROOT}/port/native/patches/0029-release-bot-resources-on-map-change.patch"
    "${ROOT}/port/native/patches/0031-use-bot-enemy-for-cover-target.patch"
    "${ROOT}/port/native/patches/0033-report-bot-navigation-status.patch"
    "${ROOT}/port/native/patches/0034-enter-bots-through-player-lifecycle.patch"
    "${ROOT}/port/native/patches/0038-resolve-bare-damage-blob-materials.patch"
)

stage_project_files() {
    local source_root="$1" stage_root="$2"
    tar -cf - \
        --exclude='public/source' \
        --exclude='public/js/voicechat.js' \
        --exclude='__pycache__' \
        --exclude='*/__pycache__' \
        --exclude='*/__pycache__/*' \
        --exclude='*.py[cod]' \
        -C "${source_root}" \
        .gitignore \
        AGENTS.md \
        content-src \
        DEPLOY.md \
        SOURCE_BUILD.md \
        deploy \
        mods/README.md \
        mods/maps \
        package.json \
        package-lock.json \
        port \
        public \
        server \
        server.js \
        tests \
        tools \
        | tar -xf - -C "${stage_root}"
}

if [[ "${1:-}" == "--stage-project-files-only" ]]; then
    [[ $# -eq 3 ]] || {
        echo "usage: $0 --stage-project-files-only <source-root> <stage-root>" >&2
        exit 64
    }
    mkdir -p -- "$3"
    stage_project_files "$2" "$3"
    python3 "${SOURCE_TREE_AUDITOR}" "$3"
    exit 0
elif (($#)); then
    echo "usage: $0 [--stage-project-files-only <source-root> <stage-root>]" >&2
    exit 64
fi

for command in chmod git grep install patch python3 sha256sum tar xz; do
    command -v "${command}" >/dev/null 2>&1 || {
        echo "Required command is missing: ${command}" >&2
        exit 1
    }
done
for source in "${OPENQ4_VENDOR}" "${GAMELIBS_VENDOR}"; do
    [[ -e "${source}/.git" ]] || {
        echo "Missing pinned source checkout: ${source}" >&2
        exit 1
    }
done
[[ "$(git -C "${OPENQ4_VENDOR}" rev-parse HEAD)" == "${OPENQ4_PIN}" ]] || {
    echo "OpenQ4 checkout is not at the published pin" >&2
    exit 1
}
[[ "$(git -C "${GAMELIBS_VENDOR}" rev-parse HEAD)" == "${GAMELIBS_PIN}" ]] || {
    echo "OpenQ4-GameLibs checkout is not at the published pin" >&2
    exit 1
}
git -C "${OPENQ4_VENDOR}" cat-file -e "${OPENQ4_PIN}^{commit}"
git -C "${GAMELIBS_VENDOR}" cat-file -e "${GAMELIBS_PIN}^{commit}"
[[ "$(git -C "${OPENQ4_VENDOR}" rev-parse "${OPENQ4_PIN}^{tree}")" == "${OPENQ4_TREE}" ]] || {
    echo "OpenQ4 pinned tree does not match the source-offer trust anchor" >&2
    exit 1
}
[[ "$(git -C "${GAMELIBS_VENDOR}" rev-parse "${GAMELIBS_PIN}^{tree}")" == "${GAMELIBS_TREE}" ]] || {
    echo "OpenQ4-GameLibs pinned tree does not match the source-offer trust anchor" >&2
    exit 1
}
for patch_file in "${OPENQ4_PATCH_FILES[@]}" "${GAMELIBS_PATCH_FILES[@]}"; do
    [[ -f "${patch_file}" ]] || {
        echo "Required source patch is missing: ${patch_file}" >&2
        exit 1
    }
done
for patch_file in "${OPENQ4_PATCH_FILES[@]}"; do
    if grep -Eq '^diff --git a/content/baseoq4/pak[01]/' "${patch_file}"; then
        echo "OpenQ4 source patch redistributes a pruned runtime-pack file: ${patch_file}" >&2
        exit 1
    fi
done
[[ -f "${ROOT}/engine/SHA256SUMS" ]] || {
    echo "Frozen engine SHA256SUMS is missing" >&2
    exit 1
}
[[ -d "${FREE_RUNTIME_SOURCE}" && -f "${FREE_RUNTIME_POLICY}" \
    && -f "${FREE_RUNTIME_BUILDER}" && -f "${SOURCE_TREE_AUDITOR}" \
    && -f "${SOURCE_IDENTITY_TOOL}" && -f "${NATIVE_SOURCE_IDENTITY_TOOL}" ]] || {
    echo "Free runtime source, policy, builder, or source-offer verifier is missing" >&2
    exit 1
}

patch_manifest_sha="$({
    for patch_file in "${OPENQ4_PATCH_FILES[@]}" "${GAMELIBS_PATCH_FILES[@]}"; do
        printf '%s %s\n' \
            "$(basename -- "${patch_file}")" \
            "$(sha256sum "${patch_file}" | awk '{print $1}')"
    done
} | sha256sum | awk '{print $1}')"
free_runtime_source_sha="$(
    python3 "${FREE_RUNTIME_BUILDER}" --source "${FREE_RUNTIME_SOURCE}" --fingerprint-only
)"
[[ "${free_runtime_source_sha}" =~ ^[0-9a-f]{64}$ ]] || {
    echo "Free runtime builder returned an invalid source fingerprint" >&2
    exit 1
}
python3 - "${FREE_RUNTIME_POLICY}" "${free_runtime_source_sha}" <<'PY'
import json
import pathlib
import sys

policy = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
if policy.get("schema") != 1 or policy.get("policy") != "free-only-v1":
    raise SystemExit("free runtime policy schema is invalid")
if policy.get("runtimeSourceSha256") != sys.argv[2]:
    raise SystemExit("free runtime source fingerprint differs from its pinned policy")
PY

(
    cd "${ROOT}/engine"
    sha256sum --strict -c SHA256SUMS
)
python3 - \
    "${ROOT}/engine/engine-manifest.json" \
    "${ROOT}/engine/build-info.txt" \
    "${OPENQ4_PIN}" "${GAMELIBS_PIN}" "${patch_manifest_sha}" \
    "${free_runtime_source_sha}" <<'PY'
import json
import pathlib
import sys

manifest_path, info_path = map(pathlib.Path, sys.argv[1:3])
openq4_pin, gamelibs_pin, patch_sha, runtime_sha = sys.argv[3:7]
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("schema") != 2:
    raise SystemExit("source offer requires a schema-2 frozen engine manifest")

info = {}
for line in info_path.read_text(encoding="utf-8").splitlines():
    key, separator, value = line.partition("=")
    if not separator or key in info:
        raise SystemExit(f"invalid frozen engine build-info line: {line!r}")
    info[key] = value
expected = {
    "openq4": openq4_pin,
    "gamelibs": gamelibs_pin,
    "patch_manifest_sha256": patch_sha,
    "runtime_source_sha256": runtime_sha,
    "linkage": "emscripten-main-module-plus-side-module",
}
for key, value in expected.items():
    if info.get(key) != value:
        raise SystemExit(
            f"frozen engine build-info {key} mismatch: expected {value}, got {info.get(key)}"
        )
expected_build = (
    f"openq4-{openq4_pin[:12]}-gamelibs-{gamelibs_pin[:12]}-p{patch_sha[:12]}"
    f"-r{runtime_sha[:12]}"
)
if info.get("build") != expected_build or manifest.get("build") != expected_build:
    raise SystemExit("frozen engine build id does not match the pinned sources and patch manifest")
PY

mkdir -p -- "${ROOT}/build" "${OUT_DIR}"
STAGE_PARENT="$(mktemp -d "${ROOT}/build/source-offer.XXXXXX")"
STAGE_ROOT="${STAGE_PARENT}/q4-web-source"
TMP_ARCHIVE="$(mktemp "${OUT_DIR}/.${OUT_NAME}.XXXXXX")"
TMP_SUM="$(mktemp "${OUT_DIR}/.${OUT_NAME}.sha256.XXXXXX")"
cleanup() {
    rm -rf -- "${STAGE_PARENT}"
    rm -f -- "${TMP_ARCHIVE}" "${TMP_SUM}"
}
trap cleanup EXIT

mkdir -p -- \
    "${STAGE_ROOT}/vendor/openq4" \
    "${STAGE_ROOT}/vendor/openq4-game"
git -C "${OPENQ4_VENDOR}" archive --format=tar "${OPENQ4_PIN}" \
    | tar -xf - -C "${STAGE_ROOT}/vendor/openq4"
git -C "${GAMELIBS_VENDOR}" archive --format=tar "${GAMELIBS_PIN}" \
    | tar -xf - -C "${STAGE_ROOT}/vendor/openq4-game"

# Upstream's repository mixes engine source with media whose redistribution
# status is not established by its code license. The web build needs none of
# these paths; patch 0013 also removes Emscripten's Meson references to icons.
rm -rf -- \
    "${STAGE_ROOT}/vendor/openq4/.install" \
    "${STAGE_ROOT}/vendor/openq4/assets" \
    "${STAGE_ROOT}/vendor/openq4/content/baseoq4/pak0" \
    "${STAGE_ROOT}/vendor/openq4/content/baseoq4/pak1" \
    "${STAGE_ROOT}/vendor/openq4/src/sys/linux/setup/image" \
    "${STAGE_ROOT}/vendor/openq4/src/sys/win32/rc/res" \
    "${STAGE_ROOT}/vendor/openq4/src/tools/Help/images"

# Authenticate the exact code-only export independently of the complete
# upstream tree recorded below. The marker itself is intentionally excluded.
git -C "${STAGE_ROOT}/vendor/openq4" init --quiet
git -C "${STAGE_ROOT}/vendor/openq4" add --force --all
staged_openq4_export_tree="$(
    git -C "${STAGE_ROOT}/vendor/openq4" write-tree
)"
rm -rf -- "${STAGE_ROOT}/vendor/openq4/.git"
[[ "${staged_openq4_export_tree}" == "${OPENQ4_EXPORT_TREE}" ]] || {
    echo "Sanitized OpenQ4 export tree does not match its trust anchor" >&2
    exit 1
}

printf 'commit=%s\ntree=%s\nexport-tree=%s\n' \
    "${OPENQ4_PIN}" "${OPENQ4_TREE}" "${OPENQ4_EXPORT_TREE}" \
    >"${STAGE_ROOT}/vendor/openq4/.source-revision"
printf 'commit=%s\ntree=%s\n' "${GAMELIBS_PIN}" "${GAMELIBS_TREE}" \
    >"${STAGE_ROOT}/vendor/openq4-game/.source-revision"

# Keep this whitelist explicit. The source archive has everything needed to
# build/test the web port, but no frozen binaries, runtime state, or retail data.
stage_project_files "${ROOT}" "${STAGE_ROOT}"
python3 "${SOURCE_TREE_AUDITOR}" "${STAGE_ROOT}"

mkdir -p -- "${STAGE_ROOT}/.selfcheck"
for test_file in "${ROOT}"/.selfcheck/gateway*.test.mjs; do
    [[ -f "${test_file}" ]] || continue
    install -m 0644 "${test_file}" "${STAGE_ROOT}/.selfcheck/$(basename -- "${test_file}")"
done

[[ -f /srv/_shared/voice/voicechat.js && ! -L /srv/_shared/voice/voicechat.js ]] || {
    echo "Canonical shared voice module is missing or is not a regular file" >&2
    exit 1
}
mkdir -p -- "${STAGE_ROOT}/_shared/voice"
install -m 0644 /srv/_shared/voice/voicechat.js \
    "${STAGE_ROOT}/_shared/voice/voicechat.js"
ln -s -- ../../_shared/voice/voicechat.js \
    "${STAGE_ROOT}/public/js/voicechat.js"

python3 "${SOURCE_IDENTITY_TOOL}" write \
    --root "${ROOT}" \
    --output "${STAGE_ROOT}/SOURCE_MANIFEST.json" \
    --openq4 "${OPENQ4_PIN}" \
    --gamelibs "${GAMELIBS_PIN}" \
    --patch-manifest "${patch_manifest_sha}" \
    --runtime-source "${free_runtime_source_sha}"

python3 - \
    "${STAGE_ROOT}" \
    "${OPENQ4_PIN}" "${OPENQ4_TREE}" "${OPENQ4_EXPORT_TREE}" \
    "${GAMELIBS_PIN}" "${GAMELIBS_TREE}" <<'PY'
import os
import pathlib
import sys

root = pathlib.Path(sys.argv[1]).resolve()
pins = {
    pathlib.PurePosixPath("vendor/openq4/.source-revision"): (
        f"commit={sys.argv[2]}\ntree={sys.argv[3]}\nexport-tree={sys.argv[4]}\n"
    ),
    pathlib.PurePosixPath("vendor/openq4-game/.source-revision"): (
        f"commit={sys.argv[5]}\ntree={sys.argv[6]}\n"
    ),
}
for relative, expected in pins.items():
    marker = root / relative
    if marker.is_symlink() or marker.read_text(encoding="ascii") != expected:
        raise SystemExit(f"invalid staged source marker: {relative}")

for relative in (
    "vendor/openq4/.install",
    "vendor/openq4/assets",
    "vendor/openq4/content/baseoq4/pak0",
    "vendor/openq4/content/baseoq4/pak1",
    "vendor/openq4/src/sys/linux/setup/image",
    "vendor/openq4/src/sys/win32/rc/res",
    "vendor/openq4/src/tools/Help/images",
):
    if (root / relative).exists():
        raise SystemExit(f"unproven upstream media path in source offer: {relative}")

for path in root.rglob("*"):
    relative = path.relative_to(root)
    if any(part.casefold() == "__pycache__" for part in relative.parts):
        raise SystemExit(f"generated Python cache in source offer: {relative}")
    if path.name.casefold().endswith((".pyc", ".pyo", ".pyd")):
        raise SystemExit(f"generated Python bytecode in source offer: {relative}")
    if ".git" in relative.parts:
        raise SystemExit(f"nested Git metadata in source offer: {relative}")
    if relative.parts and relative.parts[0] in {
        "assets-src", "build", "content", "engine", "node_modules", "replays", "runtime"
    }:
        raise SystemExit(f"private/generated top-level path in source offer: {relative}")
    if relative.parts[:2] in {("mods", "dist"), ("public", "source")}:
        raise SystemExit(f"generated subtree in source offer: {relative}")
    if path.name.lower().endswith((".pk4", ".wasm", ".so", ".a", ".o", ".dylib", ".dll", ".exe")):
        raise SystemExit(f"generated binary or PK4 in source offer: {relative}")
    if relative.parts[:2] == ("vendor", "openq4") and path.name.lower().endswith(
        (".bmp", ".dds", ".flac", ".icns", ".ico", ".jpeg", ".jpg", ".mp3", ".ogg", ".png", ".tga", ".wav")
    ):
        raise SystemExit(f"unproven upstream media file in source offer: {relative}")
    if path.is_symlink():
        target = os.readlink(path)
        if os.path.isabs(target):
            raise SystemExit(f"absolute symlink in source offer: {relative} -> {target}")
        resolved = (path.parent / target).resolve(strict=False)
        if not resolved.is_relative_to(root):
            raise SystemExit(f"escaping symlink in source offer: {relative} -> {target}")

voice_link = root / "public/js/voicechat.js"
if not voice_link.is_symlink() or os.readlink(voice_link) != "../../_shared/voice/voicechat.js":
    raise SystemExit("source offer voicechat link is not the canonical relative link")
if not (root / "_shared/voice/voicechat.js").is_file():
    raise SystemExit("source offer does not contain the shared voice implementation")
PY

tar \
    --sort=name \
    --mtime='UTC 2026-07-14' \
    --owner=0 --group=0 --numeric-owner \
    -I 'xz -T2 -6' \
    -C "${STAGE_PARENT}" \
    -cf "${TMP_ARCHIVE}" \
    q4-web-source
archive_sha="$(sha256sum "${TMP_ARCHIVE}" | awk '{print $1}')"
printf '%s  %s\n' "${archive_sha}" "${OUT_NAME}" >"${TMP_SUM}"
chmod 0644 "${TMP_ARCHIVE}" "${TMP_SUM}"

# Both files are complete before either public pathname is replaced.
mv -f -- "${TMP_ARCHIVE}" "${OUT}"
mv -f -- "${TMP_SUM}" "${SUM_OUT}"
printf 'Staged source offer: %s (%s bytes, sha256 %s)\n' \
    "${OUT}" "$(stat -c %s "${OUT}")" "${archive_sha}"
