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

ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
OPENQ4_VENDOR="${ROOT}/vendor/openq4"
GAMELIBS_VENDOR="${ROOT}/vendor/openq4-game"
SOURCE_ROOT="${ROOT}/build/wasm-src/openq4"
GAMELIBS_ROOT="${ROOT}/build/wasm-src/openq4-game"
STAMP_FILE="${ROOT}/build/wasm-src/source-stamp"
SP_BUILD_ROOT="${ROOT}/build/wasm-release-sp"
MP_BUILD_ROOT="${ROOT}/build/wasm-release-mp"
SP_GAME_WASM="${SP_BUILD_ROOT}/content/baseoq4/game-sp_wasm.wasm"
MP_GAME_WASM="${MP_BUILD_ROOT}/content/baseoq4/game-mp_wasm.wasm"
ENGINE_ROOT="${ROOT}/engine"
CROSS_FILE="${ROOT}/port/wasm/emscripten.ini"
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"
FREE_RUNTIME_EXPECTED="${ROOT}/build/wasm-free-runtime"
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"
)
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"
)

OPENQ4_PIN="975e296b80b66f63b817d16ea78b53e0685c5c55"
GAMELIBS_PIN="eb26e5f3a52358c58ee00bee5602575e5b057164"
OPENQ4_TREE="bcdff22ce6682e16586f66b2f2df6ae0f3665a60"
OPENQ4_EXPORT_TREE="3a4d99cbd63c4ed29c47d5d9708a55e80447bb52"
GAMELIBS_TREE="9035a51f4901dceb16449927e2d9ef36afee6d02"
# Pin compiler-provided __DATE__/__TIME__ to the OpenQ4 commit timestamp.
# Clang and Emscripten honor SOURCE_DATE_EPOCH for these built-in macros.
OPENQ4_SOURCE_DATE_EPOCH="1783674075"
JOBS="${JOBS:-4}"
CLEAN=0
VERIFY_SOURCES_ONLY=0

usage() {
    cat <<'EOF'
Usage: tools/build-wasm.sh [--clean] [--jobs N] [--verify-sources-only]

Builds pinned OpenQ4 into separate single-player and multiplayer WebAssembly
main modules plus SDK GameLib side modules, verifies their source and linkage
split, and atomically stages the frozen artifacts and engine manifest under
engine/. No retail Quake 4 data is copied. With --verify-sources-only, materializes
and patches the pinned source inputs, runs the source checks, and exits before
Meson setup or compilation.
EOF
}

while (($#)); do
    case "$1" in
        --clean)
            CLEAN=1
            shift
            ;;
        --jobs)
            [[ $# -ge 2 && "$2" =~ ^[1-9][0-9]*$ ]] || {
                echo "--jobs requires a positive integer" >&2
                exit 2
            }
            JOBS="$2"
            shift 2
            ;;
        --verify-sources-only)
            VERIFY_SOURCES_ONLY=1
            shift
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            echo "Unknown argument: $1" >&2
            usage >&2
            exit 2
            ;;
    esac
done

required_commands=(awk cp find git grep python3 sha256sum)
if ((!VERIFY_SOURCES_ONLY)); then
    required_commands+=(meson ninja install cmp)
fi
for command in "${required_commands[@]}"; do
    command -v "${command}" >/dev/null 2>&1 || {
        echo "Required command is missing: ${command}" >&2
        exit 1
    }
done

if ((!VERIFY_SOURCES_ONLY)); then
    [[ -f "${CROSS_FILE}" ]] || {
        echo "Emscripten Meson cross file is missing: ${CROSS_FILE}" >&2
        exit 1
    }
    for tool in \
        /opt/emsdk/upstream/emscripten/emcc \
        /opt/emsdk/upstream/emscripten/em++ \
        /opt/emsdk/upstream/emscripten/emar \
        /opt/emsdk/node/22.16.0_64bit/bin/node; do
        [[ -x "${tool}" ]] || {
            echo "Pinned Emscripten tool is missing: ${tool}" >&2
            exit 1
        }
    done
fi
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
[[ -d "${FREE_RUNTIME_SOURCE}/pak0" && -d "${FREE_RUNTIME_SOURCE}/pak1" ]] || {
    echo "Self-authored free runtime pack sources are missing: ${FREE_RUNTIME_SOURCE}" >&2
    exit 1
}
[[ -f "${FREE_RUNTIME_POLICY}" && -f "${FREE_RUNTIME_BUILDER}" ]] || {
    echo "Free runtime policy or deterministic builder is missing" >&2
    exit 1
}
source_input_kind=""
source_input_tree=""
source_input_export_tree=""

verify_source_input() {
    local source_dir="$1" expected_pin="$2" expected_tree="$3" expected_export_tree="$4" label="$5"
    local actual_pin="" marker_export_tree="" marker_lines=()

    [[ -d "${source_dir}" ]] || {
        echo "Missing ${label} source input: ${source_dir}" >&2
        exit 1
    }
    if [[ -e "${source_dir}/.git" ]]; then
        actual_pin="$(git -C "${source_dir}" rev-parse HEAD)"
        [[ "${actual_pin}" == "${expected_pin}" ]] || {
            echo "${label} pin mismatch: expected ${expected_pin}, got ${actual_pin}" >&2
            exit 1
        }
        source_input_tree="$(git -C "${source_dir}" rev-parse "${expected_pin}^{tree}")"
        [[ "${source_input_tree}" == "${expected_tree}" ]] || {
            echo "${label} tree mismatch: expected ${expected_tree}, got ${source_input_tree}" >&2
            exit 1
        }
        source_input_kind="git"
        # Local trusted checkouts retain the complete pinned tree. Only the
        # source-offer snapshot uses the separately pinned pruned export tree.
        source_input_export_tree="${expected_tree}"
        return
    fi

    [[ -f "${source_dir}/.source-revision" && ! -L "${source_dir}/.source-revision" ]] || {
        echo "${label} is neither a Git checkout nor a marked source snapshot" >&2
        exit 1
    }
    if find "${source_dir}" -mindepth 1 -name .git -print -quit | grep -q .; then
        echo "${label} snapshot contains a nested .git entry" >&2
        exit 1
    fi
    mapfile -t marker_lines <"${source_dir}/.source-revision"
    [[ ${#marker_lines[@]} -eq 2 || ${#marker_lines[@]} -eq 3 ]] || {
        echo "${label} snapshot has an invalid .source-revision marker" >&2
        exit 1
    }
    [[ "${marker_lines[0]}" =~ ^commit=([0-9a-f]{40})$ ]] || {
        echo "${label} snapshot has an invalid commit marker" >&2
        exit 1
    }
    actual_pin="${BASH_REMATCH[1]}"
    [[ "${actual_pin}" == "${expected_pin}" ]] || {
        echo "${label} snapshot pin mismatch: expected ${expected_pin}, got ${actual_pin}" >&2
        exit 1
    }
    [[ "${marker_lines[1]}" =~ ^tree=([0-9a-f]{40})$ ]] || {
        echo "${label} snapshot has an invalid tree marker" >&2
        exit 1
    }
    source_input_tree="${BASH_REMATCH[1]}"
    [[ "${source_input_tree}" == "${expected_tree}" ]] || {
        echo "${label} snapshot tree marker mismatch: expected ${expected_tree}, got ${source_input_tree}" >&2
        exit 1
    }
    marker_export_tree="${source_input_tree}"
    if [[ ${#marker_lines[@]} -eq 3 ]]; then
        [[ "${marker_lines[2]}" =~ ^export-tree=([0-9a-f]{40})$ ]] || {
            echo "${label} snapshot has an invalid export-tree marker" >&2
            exit 1
        }
        marker_export_tree="${BASH_REMATCH[1]}"
    fi
    [[ "${marker_export_tree}" == "${expected_export_tree}" ]] || {
        echo "${label} snapshot export-tree marker mismatch: expected ${expected_export_tree}, got ${marker_export_tree}" >&2
        exit 1
    }
    source_input_kind="snapshot"
    source_input_export_tree="${marker_export_tree}"
}

verify_source_input \
    "${OPENQ4_VENDOR}" "${OPENQ4_PIN}" "${OPENQ4_TREE}" "${OPENQ4_EXPORT_TREE}" OpenQ4
OPENQ4_SOURCE_KIND="${source_input_kind}"
OPENQ4_SOURCE_TREE="${source_input_tree}"
OPENQ4_SOURCE_EXPORT_TREE="${source_input_export_tree}"
verify_source_input \
    "${GAMELIBS_VENDOR}" "${GAMELIBS_PIN}" "${GAMELIBS_TREE}" "${GAMELIBS_TREE}" OpenQ4-game
GAMELIBS_SOURCE_KIND="${source_input_kind}"
GAMELIBS_SOURCE_TREE="${source_input_tree}"
GAMELIBS_SOURCE_EXPORT_TREE="${source_input_export_tree}"

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
expected_stamp="${OPENQ4_PIN}@${OPENQ4_TREE}:${OPENQ4_SOURCE_EXPORT_TREE}|${GAMELIBS_PIN}@${GAMELIBS_TREE}:${GAMELIBS_SOURCE_EXPORT_TREE}|${patch_manifest_sha}|${free_runtime_source_sha}"
build_id="openq4-${OPENQ4_PIN:0:12}-gamelibs-${GAMELIBS_PIN:0:12}-p${patch_manifest_sha:0:12}-r${free_runtime_source_sha:0:12}"

openq4_materialized_diff_sha() {
    git -C "${SOURCE_ROOT}" add -N .
    git -C "${SOURCE_ROOT}" diff --binary --no-ext-diff -- . \
        ':(exclude)content/baseoq4/pak0' \
        ':(exclude)content/baseoq4/pak0/**' \
        ':(exclude)content/baseoq4/pak1' \
        ':(exclude)content/baseoq4/pak1/**' \
        | sha256sum | awk '{print $1}'
}

gamelibs_materialized_diff_sha() {
    git -C "${GAMELIBS_ROOT}" add -N .
    git -C "${GAMELIBS_ROOT}" diff --binary --no-ext-diff \
        | sha256sum | awk '{print $1}'
}

if ((CLEAN)); then
    rm -rf -- "${ROOT}/build/wasm-src"
    if ((!VERIFY_SOURCES_ONLY)); then
        rm -rf -- "${SP_BUILD_ROOT}" "${MP_BUILD_ROOT}"
    fi
fi
rm -rf -- "${FREE_RUNTIME_EXPECTED}"
mkdir -p -- "${FREE_RUNTIME_EXPECTED}"
python3 "${FREE_RUNTIME_BUILDER}" \
    --source "${FREE_RUNTIME_SOURCE}" \
    --output "${FREE_RUNTIME_EXPECTED}" \
    --manifest "${FREE_RUNTIME_EXPECTED}/manifest.json" >/dev/null

materialize_source() {
    local source_dir="$1" destination="$2" pin="$3" expected_tree="$4" expected_export_tree="$5" kind="$6" label="$7"
    local actual_tree=""

    if [[ "${kind}" == "git" ]]; then
        git clone --quiet --no-checkout "${source_dir}" "${destination}"
        git -C "${destination}" checkout --quiet --detach "${pin}"
        actual_tree="$(git -C "${destination}" rev-parse 'HEAD^{tree}')"
        [[ "${actual_tree}" == "${expected_tree}" ]] || {
            echo "${label} materialized Git tree mismatch: expected ${expected_tree}, got ${actual_tree}" >&2
            exit 1
        }
        return
    fi

    mkdir -p -- "${destination}"
    cp -a -- "${source_dir}/." "${destination}/"
    rm -f -- "${destination}/.source-revision"
    git -C "${destination}" init --quiet
    git -C "${destination}" config user.name "OpenQ4 source snapshot"
    git -C "${destination}" config user.email "source-snapshot@localhost.invalid"
    # A fresh repository has no tracked-file exception to upstream ignore rules.
    # Force-add the full archive so write-tree covers every exported path.
    git -C "${destination}" add --force --all
    actual_tree="$(git -C "${destination}" write-tree)"
    [[ "${actual_tree}" == "${expected_export_tree}" ]] || {
        echo "${label} materialized snapshot tree mismatch: expected ${expected_export_tree}, got ${actual_tree}" >&2
        exit 1
    }
    git -C "${destination}" commit --quiet -m "Pinned ${label} snapshot ${pin}"
    git -C "${destination}" config q4.sourceRevision "${pin}"
    git -C "${destination}" config q4.sourceTree "${expected_tree}"
    git -C "${destination}" config q4.sourceExportTree "${expected_export_tree}"
}

verify_materialized_source() {
    local source_dir="$1" expected_pin="$2" expected_tree="$3" expected_export_tree="$4"
    local configured_pin="" configured_tree="" configured_export_tree="" actual_tree=""
    configured_pin="$(git -C "${source_dir}" config --local --get q4.sourceRevision || true)"
    if [[ -n "${configured_pin}" ]]; then
        configured_tree="$(git -C "${source_dir}" config --local --get q4.sourceTree || true)"
        configured_export_tree="$(git -C "${source_dir}" config --local --get q4.sourceExportTree || true)"
        [[ "${configured_pin}" == "${expected_pin}" \
            && "${configured_tree}" == "${expected_tree}" \
            && "${configured_export_tree}" == "${expected_export_tree}" ]]
    else
        [[ "$(git -C "${source_dir}" rev-parse HEAD)" == "${expected_pin}" ]]
    fi
    actual_tree="$(git -C "${source_dir}" rev-parse 'HEAD^{tree}')"
    [[ "${actual_tree}" == "${expected_export_tree}" ]]
}

new_source_stamp=0
if [[ ! -f "${STAMP_FILE}" ]]; then
    if [[ -e "${SOURCE_ROOT}" || -e "${GAMELIBS_ROOT}" ]]; then
        echo "WebAssembly source copy exists without a valid stamp; rerun with --clean" >&2
        exit 1
    fi

    mkdir -p -- "$(dirname -- "${SOURCE_ROOT}")"
    materialize_source \
        "${OPENQ4_VENDOR}" "${SOURCE_ROOT}" "${OPENQ4_PIN}" "${OPENQ4_SOURCE_TREE}" \
        "${OPENQ4_SOURCE_EXPORT_TREE}" "${OPENQ4_SOURCE_KIND}" OpenQ4
    materialize_source \
        "${GAMELIBS_VENDOR}" "${GAMELIBS_ROOT}" "${GAMELIBS_PIN}" "${GAMELIBS_SOURCE_TREE}" \
        "${GAMELIBS_SOURCE_EXPORT_TREE}" "${GAMELIBS_SOURCE_KIND}" OpenQ4-game
    for patch_file in "${OPENQ4_PATCH_FILES[@]}"; do
        echo "Applying OpenQ4 patch: $(basename -- "${patch_file}")"
        git -C "${SOURCE_ROOT}" apply --check "${patch_file}"
        git -C "${SOURCE_ROOT}" apply "${patch_file}"
    done
    for patch_file in "${GAMELIBS_PATCH_FILES[@]}"; do
        echo "Applying OpenQ4-game patch: $(basename -- "${patch_file}")"
        git -C "${GAMELIBS_ROOT}" apply --check "${patch_file}"
        git -C "${GAMELIBS_ROOT}" apply "${patch_file}"
    done
    new_source_stamp=1
else
    mapfile -t source_stamp_lines <"${STAMP_FILE}"
    if [[ ${#source_stamp_lines[@]} -ne 3 \
        || "${source_stamp_lines[0]}" != "${expected_stamp}" \
        || ! "${source_stamp_lines[1]}" =~ ^openq4_diff_sha256=([0-9a-f]{64})$ \
        || ! "${source_stamp_lines[2]}" =~ ^gamelibs_diff_sha256=([0-9a-f]{64})$ ]]; then
        echo "WebAssembly source stamp is stale or malformed; rerun with --clean" >&2
        exit 1
    fi
fi

verify_materialized_source \
    "${SOURCE_ROOT}" "${OPENQ4_PIN}" "${OPENQ4_TREE}" "${OPENQ4_SOURCE_EXPORT_TREE}"
verify_materialized_source \
    "${GAMELIBS_ROOT}" "${GAMELIBS_PIN}" "${GAMELIBS_TREE}" "${GAMELIBS_SOURCE_EXPORT_TREE}"
openq4_diff_sha="$(openq4_materialized_diff_sha)"
gamelibs_diff_sha="$(gamelibs_materialized_diff_sha)"
if ((new_source_stamp)); then
    printf '%s\nopenq4_diff_sha256=%s\ngamelibs_diff_sha256=%s\n' \
        "${expected_stamp}" "${openq4_diff_sha}" "${gamelibs_diff_sha}" >"${STAMP_FILE}"
elif [[ "${source_stamp_lines[1]}" != "openq4_diff_sha256=${openq4_diff_sha}" \
    || "${source_stamp_lines[2]}" != "gamelibs_diff_sha256=${gamelibs_diff_sha}" ]]; then
    echo "WebAssembly materialized patched source differs from its stamp; rerun with --clean" >&2
    exit 1
fi
rm -rf -- \
    "${SOURCE_ROOT}/content/baseoq4/pak0" \
    "${SOURCE_ROOT}/content/baseoq4/pak1"
mkdir -p -- "${SOURCE_ROOT}/content/baseoq4"
cp -a -- "${FREE_RUNTIME_SOURCE}/pak0" "${SOURCE_ROOT}/content/baseoq4/pak0"
cp -a -- "${FREE_RUNTIME_SOURCE}/pak1" "${SOURCE_ROOT}/content/baseoq4/pak1"
git -C "${SOURCE_ROOT}" add -N .
git -C "${GAMELIBS_ROOT}" add -N .
git -C "${SOURCE_ROOT}" diff --check -- . \
    ':(exclude)content/baseoq4/pak0' \
    ':(exclude)content/baseoq4/pak0/**' \
    ':(exclude)content/baseoq4/pak1' \
    ':(exclude)content/baseoq4/pak1/**'
git -C "${GAMELIBS_ROOT}" diff --check
grep -Fq '#define ASYNC_WRITE_TAGS 1' "${GAMELIBS_ROOT}/src/mpgame/Game_network.cpp"
grep -Fq 'GAME_RELIABLE_MESSAGE_EVENT_ACK' "${GAMELIBS_ROOT}/src/mpgame/Game_network.cpp"
grep -Fq 'MaintainBotMinPlayers();' "${GAMELIBS_ROOT}/src/mpgame/Game_local.cpp"
test -f "${GAMELIBS_ROOT}/src/mpgame/bots/Bot.cpp"
grep -Fq 'Background downloads use the synchronous browser fallback' "${SOURCE_ROOT}/src/framework/FileSystem.cpp"
grep -Fq 'background URL downloads are unavailable in the browser' "${SOURCE_ROOT}/src/framework/FileSystem.cpp"
grep -Fq 'SDL_HINT_EMSCRIPTEN_CANVAS_SELECTOR, "#gameCanvas"' "${SOURCE_ROOT}/src/sys/sdl3/sdl3_backend.cpp"
grep -Fq 'unzlocal_CacheCentralDirectory' "${SOURCE_ROOT}/src/framework/Unzip.cpp"
grep -Fq 'stream = fmemopen' "${SOURCE_ROOT}/src/framework/Unzip.cpp"
grep -Fq -- '-sGROWABLE_ARRAYBUFFERS=0' "${SOURCE_ROOT}/meson.build"
grep -Fq '#if defined( __EMSCRIPTEN__ )' "${SOURCE_ROOT}/src/sys/posix/posix_main.cpp"
grep -Fq 'if( openalContext != NULL )' "${SOURCE_ROOT}/src/sound/OpenAL/AL_SoundHardware.cpp"
grep -Fq 'if( openalDevice != NULL )' "${SOURCE_ROOT}/src/sound/OpenAL/AL_SoundHardware.cpp"
python3 "${SOURCE_ROOT}/tools/tests/gamelibs_staging.py"

if ((VERIFY_SOURCES_ONLY)); then
    cat <<EOF
OpenQ4 WebAssembly source verification passed.
openq4=${OPENQ4_PIN}
gamelibs=${GAMELIBS_PIN}
patch_manifest_sha256=${patch_manifest_sha}
free_runtime_source_sha256=${free_runtime_source_sha}
openq4_patched_diff_sha256=${openq4_diff_sha}
gamelibs_patched_diff_sha256=${gamelibs_diff_sha}
EOF
    exit 0
fi

mkdir -p -- "${ROOT}/build/tmp"
export TMPDIR="${ROOT}/build/tmp"
export OPENQ4_GAMELIBS_REPO="${GAMELIBS_ROOT}"
export SOURCE_DATE_EPOCH="${OPENQ4_SOURCE_DATE_EPOCH}"

setup_variant() {
    local build_root="$1" sp="$2" mp="$3"
    local setup_args=(
        "${build_root}"
        "${SOURCE_ROOT}"
        --backend ninja
        --buildtype release
        --wrap-mode forcefallback
        --cross-file "${CROSS_FILE}"
        -Db_ndebug=true
        -Dplatform_backend=sdl3
        -Dbuild_engine=true
        -Dbuild_games=true
        -Dbuild_game_sp="${sp}"
        -Dbuild_game_mp="${mp}"
    )

    if [[ -f "${build_root}/meson-private/coredata.dat" ]]; then
        meson setup --reconfigure "${setup_args[@]}"
    else
        meson setup "${setup_args[@]}"
    fi
}

setup_variant "${SP_BUILD_ROOT}" true false
setup_variant "${MP_BUILD_ROOT}" false true

python3 - \
    "${SP_BUILD_ROOT}/compile_commands.json" "${SP_BUILD_ROOT}/meson-info/intro-targets.json" sp \
    "${MP_BUILD_ROOT}/compile_commands.json" "${MP_BUILD_ROOT}/meson-info/intro-targets.json" mp <<'PY'
import json
import sys

def command_text(entry):
    command = entry.get("command")
    return command if command is not None else " ".join(entry.get("arguments", []))

def link_parameters(targets, name):
    matches = [target for target in targets if target.get("name") == name]
    if len(matches) != 1:
        raise SystemExit(f"expected exactly one Meson target named {name}")
    link_rows = [row for row in matches[0].get("target_sources", []) if "linker" in row]
    if len(link_rows) != 1:
        raise SystemExit(f"expected exactly one linker command for {name}")
    return link_rows[0].get("parameters", [])

for compile_path, targets_path, mode in zip(sys.argv[1::3], sys.argv[2::3], sys.argv[3::3]):
    commands = json.load(open(compile_path, encoding="utf-8"))
    targets = json.load(open(targets_path, encoding="utf-8"))
    rows = [(entry.get("file", "").replace("\\", "/"), command_text(entry)) for entry in commands]
    if mode == "sp":
        selected = [command for path, command in rows if "/gamelibs_stage/src/game/Game_local.cpp" in path]
        forbidden = [path for path, _ in rows if "/gamelibs_stage/src/mpgame/Game_network.cpp" in path]
    else:
        selected = [command for path, command in rows if "/gamelibs_stage/src/mpgame/Game_network.cpp" in path]
        forbidden = [path for path, _ in rows if "/gamelibs_stage/src/game/Game_local.cpp" in path]
    if not selected or forbidden:
        raise SystemExit(f"{mode} compile graph does not contain exactly its own GameLib")
    if not all("-DGAME_DLL" in command and "-fPIC" in command for command in selected):
        raise SystemExit(f"{mode} GameLib is not compiled as position-independent DLL code")
    if any("OPENQ4_STATIC_" in command for _, command in rows):
        raise SystemExit(f"{mode} compile graph still contains a static GameLib define")
    engine = [command for path, command in rows if path.endswith("/src/framework/Common.cpp")]
    if not engine or not all("-sMAIN_MODULE=1" in command for command in engine):
        raise SystemExit(f"{mode} engine is not compiled as an Emscripten main module")
    expected_variant = f"-DOPENQ4_WEB_GAME_{mode.upper()}"
    other_variant = "-DOPENQ4_WEB_GAME_MP" if mode == "sp" else "-DOPENQ4_WEB_GAME_SP"
    if not all(expected_variant in command and other_variant not in command for command in engine):
        raise SystemExit(f"{mode} engine does not select exactly its own Web GameLib variant")
    if mode == "mp" and not all("-DGAME_MPAPI" in command for command in selected):
        raise SystemExit("MP GameLib command lacks -DGAME_MPAPI")
    if not all("-fexceptions" in command and "-fno-exceptions" not in command for command in engine + selected):
        raise SystemExit(f"{mode} engine or GameLib is compiled without C++ exception catching")

    main_link = link_parameters(targets, "openQ4-client_wasm")
    side_link = link_parameters(targets, f"game-{mode}_wasm")
    if not all(flag in main_link for flag in (
        "-sMAIN_MODULE=1", "-sDISABLE_EXCEPTION_CATCHING=0", "-sGROWABLE_ARRAYBUFFERS=0"
    )):
        raise SystemExit(f"{mode} engine linker does not enable MAIN_MODULE exception catching")
    if not all(flag in side_link for flag in ("-sSIDE_MODULE=1", "-sDISABLE_EXCEPTION_CATCHING=0")):
        raise SystemExit(f"{mode} GameLib linker does not enable SIDE_MODULE exception catching")
PY

meson compile -C "${SP_BUILD_ROOT}" --jobs "${JOBS}" openQ4-client_wasm game-sp_wasm
meson compile -C "${MP_BUILD_ROOT}" --jobs "${JOBS}" openQ4-client_wasm game-mp_wasm

for pack in pak0.pk4 pak1.pk4; do
    expected="${FREE_RUNTIME_EXPECTED}/${pack}"
    sp_pack="${SP_BUILD_ROOT}/baseoq4/${pack}"
    mp_pack="${MP_BUILD_ROOT}/baseoq4/${pack}"
    [[ -s "${expected}" && -s "${sp_pack}" && -s "${mp_pack}" ]] || {
        echo "Expected free runtime pack is missing after WebAssembly build: ${pack}" >&2
        exit 1
    }
    cmp "${expected}" "${sp_pack}"
    cmp "${expected}" "${mp_pack}"
done

for artifact in \
    "${SP_BUILD_ROOT}/openQ4-client_wasm.js" \
    "${SP_BUILD_ROOT}/openQ4-client_wasm.wasm" \
    "${SP_GAME_WASM}" \
    "${MP_BUILD_ROOT}/openQ4-client_wasm.js" \
    "${MP_BUILD_ROOT}/openQ4-client_wasm.wasm" \
    "${MP_GAME_WASM}"; do
    [[ -s "${artifact}" ]] || {
        echo "Expected WebAssembly artifact is missing: ${artifact}" >&2
        exit 1
    }
done
grep -Fq 'export default createOpenQ4' "${SP_BUILD_ROOT}/openQ4-client_wasm.js"
grep -Fq 'export default createOpenQ4' "${MP_BUILD_ROOT}/openQ4-client_wasm.js"
for glue in \
    "${SP_BUILD_ROOT}/openQ4-client_wasm.js" \
    "${MP_BUILD_ROOT}/openQ4-client_wasm.js"; do
    grep -Fq 'return wasmMemory.buffer;' "${glue}"
    if grep -Fq 'wasmMemory.toResizableBuffer()' "${glue}"; then
        echo "Generated runtime unexpectedly enables resizable ArrayBuffers: ${glue}" >&2
        exit 1
    fi
done
python3 - \
    "${SP_BUILD_ROOT}/openQ4-client_wasm.js" \
    "${MP_BUILD_ROOT}/openQ4-client_wasm.js" <<'PY'
import pathlib
import re
import sys

for path_arg in sys.argv[1:]:
    path = pathlib.Path(path_arg)
    source = path.read_text(encoding="utf-8")
    pre_run = re.search(r"\bpreRun\(\);", source)
    if not pre_run:
        raise SystemExit(f"generated runtime has no preRun invocation: {path}")
    # SDL3's Emscripten video backend replaces Module.requestFullscreen while
    # creating and destroying its window. Export it as a writable runtime
    # property instead of Emscripten's getter-only unexported-symbol trap.
    for method in ("FS_createPath", "FS_createDataFile", "requestFullscreen"):
        export = re.search(rf"Module\[['\"]{method}['\"]\]\s*=", source)
        if not export:
            raise SystemExit(f"generated runtime does not export {method}: {path}")
        if export.start() > pre_run.start():
            raise SystemExit(f"generated runtime exports {method} after preRun: {path}")
    # Emscripten 6.0.2 can use preliminary reduced metadata for an Asyncify
    # MAIN_MODULE and emit aborting dynCall stubs even though the final Wasm
    # exports the wrapper. Require the generated glue, not only the Wasm export.
    if "dynCalls['iiii']" not in source or "wasmExports['dynCall_iiii']" not in source:
        raise SystemExit(f"generated runtime has no dynCall_iiii export wrapper: {path}")
    if 'Attempted to invoke wasm function pointer with signature "iiii"' in source:
        raise SystemExit(f"generated runtime contains an aborting dynCall_iiii stub: {path}")
    if "Exception thrown, but exception catching is not enabled" in source:
        raise SystemExit(f"generated runtime contains an aborting C++ exception stub: {path}")
PY
[[ "$(od -An -tx1 -N4 "${SP_BUILD_ROOT}/openQ4-client_wasm.wasm" | tr -d ' \n')" == '0061736d' ]]
[[ "$(od -An -tx1 -N4 "${MP_BUILD_ROOT}/openQ4-client_wasm.wasm" | tr -d ' \n')" == '0061736d' ]]
[[ "$(od -An -tx1 -N4 "${SP_GAME_WASM}" | tr -d ' \n')" == '0061736d' ]]
[[ "$(od -An -tx1 -N4 "${MP_GAME_WASM}" | tr -d ' \n')" == '0061736d' ]]
if cmp -s "${SP_GAME_WASM}" "${MP_GAME_WASM}"; then
    echo "SP and MP GameLib side modules are unexpectedly identical" >&2
    exit 1
fi

python3 - \
    "${SP_BUILD_ROOT}/openQ4-client_wasm.wasm" \
    "${SP_GAME_WASM}" \
    "${MP_BUILD_ROOT}/openQ4-client_wasm.wasm" \
    "${MP_GAME_WASM}" <<'PY'
import pathlib
import sys

def read_uleb(data, offset):
    value = 0
    shift = 0
    while True:
        byte = data[offset]
        offset += 1
        value |= (byte & 0x7f) << shift
        if not byte & 0x80:
            return value, offset
        shift += 7

def metadata(path):
    data = pathlib.Path(path).read_bytes()
    if data[:8] != b"\0asm\x01\0\0\0":
        raise SystemExit(f"invalid WebAssembly header: {path}")
    custom = set()
    exports = set()
    offset = 8
    while offset < len(data):
        section_id = data[offset]
        size, payload = read_uleb(data, offset + 1)
        end = payload + size
        if section_id == 0:
            length, cursor = read_uleb(data, payload)
            custom.add(data[cursor:cursor + length].decode("utf-8", "replace"))
        elif section_id == 7:
            count, cursor = read_uleb(data, payload)
            for _ in range(count):
                length, cursor = read_uleb(data, cursor)
                exports.add(data[cursor:cursor + length].decode("utf-8", "replace"))
                cursor += length + 1
                _, cursor = read_uleb(data, cursor)
        offset = end
    return custom, exports

for main_path, side_path in zip(sys.argv[1::2], sys.argv[2::2]):
    _, main_exports = metadata(main_path)
    side_custom, side_exports = metadata(side_path)
    if "GetGameAPI" in main_exports:
        raise SystemExit(f"GPL main module contains the SDK GameLib entry point: {main_path}")
    if "dylink.0" not in side_custom:
        raise SystemExit(f"GameLib is not an Emscripten side module: {side_path}")
    if "GetGameAPI" not in side_exports:
        raise SystemExit(f"GameLib side module does not export GetGameAPI: {side_path}")
PY

stage_root="${ROOT}/build/engine-stage.$$"
rm -rf -- "${stage_root}"
mkdir -p -- "${stage_root}"
install -m 0644 "${SP_BUILD_ROOT}/openQ4-client_wasm.js" "${stage_root}/openq4-sp.mjs"
install -m 0644 "${SP_BUILD_ROOT}/openQ4-client_wasm.wasm" "${stage_root}/openq4-sp.wasm"
install -m 0644 "${SP_GAME_WASM}" "${stage_root}/game-sp_wasm.wasm"
install -m 0644 "${MP_BUILD_ROOT}/openQ4-client_wasm.js" "${stage_root}/openq4-mp.mjs"
install -m 0644 "${MP_BUILD_ROOT}/openQ4-client_wasm.wasm" "${stage_root}/openq4-mp.wasm"
install -m 0644 "${MP_GAME_WASM}" "${stage_root}/game-mp_wasm.wasm"

sp_script_sha="$(sha256sum "${stage_root}/openq4-sp.mjs" | awk '{print $1}')"
sp_wasm_sha="$(sha256sum "${stage_root}/openq4-sp.wasm" | awk '{print $1}')"
mp_script_sha="$(sha256sum "${stage_root}/openq4-mp.mjs" | awk '{print $1}')"
mp_wasm_sha="$(sha256sum "${stage_root}/openq4-mp.wasm" | awk '{print $1}')"
sp_game_sha="$(sha256sum "${stage_root}/game-sp_wasm.wasm" | awk '{print $1}')"
mp_game_sha="$(sha256sum "${stage_root}/game-mp_wasm.wasm" | awk '{print $1}')"

python3 - "${stage_root}" "${build_id}" \
    "${sp_script_sha}" "${sp_wasm_sha}" "${mp_script_sha}" "${mp_wasm_sha}" \
    "${sp_game_sha}" "${mp_game_sha}" <<'PY'
import json
import pathlib
import sys

root = pathlib.Path(sys.argv[1])
build = sys.argv[2]
sp_script, sp_wasm, mp_script, mp_wasm, sp_game, mp_game = sys.argv[3:9]

def versioned(path, sha256):
    return f"{path}?v={sha256}"

mp = {
    "script": versioned("/engine/openq4-mp.mjs", mp_script),
    "wasm": versioned("/engine/openq4-mp.wasm", mp_wasm),
    "module": True,
    "readySignal": "engine",
    "gameLib": {
        "source": "src/mpgame",
        "url": versioned("/engine/game-mp_wasm.wasm", mp_game),
        "filename": "game-mp_wasm.wasm",
        "sha256": mp_game,
    },
    "sha256": {"script": mp_script, "wasm": mp_wasm},
}
manifest = {
    "schema": 2,
    "build": build,
    "variants": {
        "singleplayer": {
            "script": versioned("/engine/openq4-sp.mjs", sp_script),
            "wasm": versioned("/engine/openq4-sp.wasm", sp_wasm),
            "module": True,
            "readySignal": "engine",
            "gameLib": {
                "source": "src/game",
                "url": versioned("/engine/game-sp_wasm.wasm", sp_game),
                "filename": "game-sp_wasm.wasm",
                "sha256": sp_game,
            },
            "sha256": {"script": sp_script, "wasm": sp_wasm},
        },
        "multiplayer": mp,
        "replay": dict(mp),
    },
}
(root / "engine-manifest.json").write_text(
    json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
PY

{
    printf 'openq4=%s\n' "${OPENQ4_PIN}"
    printf 'gamelibs=%s\n' "${GAMELIBS_PIN}"
    printf 'patch_manifest_sha256=%s\n' "${patch_manifest_sha}"
    printf 'runtime_source_sha256=%s\n' "${free_runtime_source_sha}"
    printf 'build=%s\n' "${build_id}"
    printf 'linkage=emscripten-main-module-plus-side-module\n'
    printf 'emscripten=%s\n' "$(/opt/emsdk/upstream/emscripten/em++ --version | head -n 1)"
} >"${stage_root}/build-info.txt"
(
    cd "${stage_root}"
    sha256sum openq4-sp.mjs openq4-sp.wasm game-sp_wasm.wasm \
        openq4-mp.mjs openq4-mp.wasm game-mp_wasm.wasm \
        engine-manifest.json build-info.txt >SHA256SUMS
)

python3 - "${stage_root}" <<'PY'
import hashlib
import json
import pathlib
import sys
import urllib.parse

root = pathlib.Path(sys.argv[1])
manifest = json.loads((root / "engine-manifest.json").read_text(encoding="utf-8"))
variants = manifest["variants"]
if variants["singleplayer"]["script"] == variants["multiplayer"]["script"]:
    raise SystemExit("SP and MP manifest scripts must differ")
if variants["replay"]["script"] != variants["multiplayer"]["script"]:
    raise SystemExit("Replay must use the MP artifact")
for variant in ("singleplayer", "multiplayer", "replay"):
    entry = variants[variant]
    if "factory" in entry:
        raise SystemExit(f"{variant} must use the Emscripten default ES module export")
    for kind in ("script", "wasm"):
        url = urllib.parse.urlsplit(entry[kind])
        path = root / pathlib.PurePosixPath(url.path).name
        digest = hashlib.sha256(path.read_bytes()).hexdigest()
        if digest != entry["sha256"][kind]:
            raise SystemExit(f"manifest hash mismatch for {variant} {kind}")
        if url.query != f"v={digest}" or url.fragment:
            raise SystemExit(f"manifest cache version mismatch for {variant} {kind}")
    game = entry["gameLib"]
    game_url = urllib.parse.urlsplit(game["url"])
    game_path = root / pathlib.PurePosixPath(game_url.path).name
    if game_path.name != game["filename"]:
        raise SystemExit(f"manifest GameLib filename mismatch for {variant}")
    if hashlib.sha256(game_path.read_bytes()).hexdigest() != game["sha256"]:
        raise SystemExit(f"manifest GameLib hash mismatch for {variant}")
    if game_url.query != f"v={game['sha256']}" or game_url.fragment:
        raise SystemExit(f"manifest GameLib cache version mismatch for {variant}")
if variants["singleplayer"]["gameLib"]["url"] == variants["multiplayer"]["gameLib"]["url"]:
    raise SystemExit("SP and MP manifest GameLibs must differ")
PY

rm -rf -- "${ENGINE_ROOT}"
mv -- "${stage_root}" "${ENGINE_ROOT}"

echo "OpenQ4 SP/MP WebAssembly artifacts staged at ${ENGINE_ROOT}"
cat "${ENGINE_ROOT}/build-info.txt"
cat "${ENGINE_ROOT}/SHA256SUMS"
