#!/usr/bin/env python3
"""Verify bare damage-blob materials resolve to their retail decal paths."""

from __future__ import annotations

import argparse
import pathlib
import re


PATCH_NAME = "0038-resolve-bare-damage-blob-materials.patch"


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


def function_body(source: str, signature: str) -> str:
    start = source.find(signature)
    if start < 0:
        raise ValueError(f"missing function {signature}")
    opening = source.find("{", start + len(signature))
    if opening < 0:
        raise ValueError(f"missing body for {signature}")
    depth = 0
    for index in range(opening, len(source)):
        if source[index] == "{":
            depth += 1
        elif source[index] == "}":
            depth -= 1
            if depth == 0:
                return source[opening + 1:index]
    raise ValueError(f"unterminated body for {signature}")


def verify_patch_lists(root: pathlib.Path) -> None:
    for relative in (
        "tools/build-wasm.sh",
        "tools/stage-source-offer.sh",
    ):
        path = root / relative
        source = path.read_text(encoding="utf-8")
        openq4 = shell_array(source, "OPENQ4_PATCH_FILES")
        gamelibs = shell_array(source, "GAMELIBS_PATCH_FILES")
        if gamelibs.count(PATCH_NAME) != 1:
            raise ValueError(f"{path} must include {PATCH_NAME} exactly once")
        if PATCH_NAME in openq4:
            raise ValueError(f"{path} classifies {PATCH_NAME} as an OpenQ4 patch")

    native_build = (root / "tools/build-native.sh").read_text(encoding="utf-8")
    if PATCH_NAME in shell_array(native_build, "OPENQ4_PATCH_FILES"):
        raise ValueError("native build classifies the browser-only patch as OpenQ4")
    if PATCH_NAME in shell_array(native_build, "GAMELIBS_PATCH_FILES"):
        raise ValueError("native build includes the browser-only GameLib patch")

    identity = (root / "tools/native-source-identity.py").read_text(encoding="utf-8")
    if PATCH_NAME in identity:
        raise ValueError("native source identity includes the browser-only GameLib patch")


def verify_variant(gamelibs_root: pathlib.Path, variant: str) -> None:
    game_local = (gamelibs_root / f"src/{variant}/Game_local.cpp").read_text(
        encoding="utf-8"
    )
    cache = function_body(
        game_local,
        "void idGameLocal::CacheDictionaryMedia( const idDict *dict )",
    )
    required_cache = (
        "materialName.Length() > 0",
        'kv->GetKey().Icmp( "mtr_blob" )',
        'materialName.Icmp( "genericdamage" )',
        'materialName = va( "textures/decals/%s", materialName.c_str() );',
        'RememberMapMediaPrecache( "material", materialName.c_str() )',
        "declManager->FindType( DECL_MATERIAL, materialName.c_str() );",
    )
    for statement in required_cache:
        if cache.count(statement) != 1:
            raise ValueError(f"{variant} precache lost damage-blob normalization: {statement}")
    if "strchr( materialName.c_str()" in cache:
        raise ValueError(f"{variant} precache rewrites arbitrary bare damage-blob materials")

    player_view = (gamelibs_root / f"src/{variant}/PlayerView.cpp").read_text(
        encoding="utf-8"
    )
    damage = function_body(
        player_view,
        "void idPlayerView::DamageImpulse( idVec3 localKickDir, const idDict *damageDef",
    )
    required_damage = (
        "const bool bareMaterialName = materialName[0]",
        '!idStr::Icmp( materialName, "genericdamage" )',
        "blobMaterial->IsImplicit()",
        "blobMaterial->TestMaterialFlag( MF_DEFAULTED )",
        'const idStr materialPath = va( "textures/decals/%s", materialName );',
    )
    for statement in required_damage:
        if damage.count(statement) != 1:
            raise ValueError(f"{variant} PlayerView lost bare-material fallback: {statement}")
    if "if ( bareMaterialName && blobMaterial != NULL" in damage:
        raise ValueError(f"{variant} PlayerView rejects arbitrary implicit bare materials")
    reject = damage.find("blobMaterial->IsImplicit()")
    fallback = damage.find("const idStr materialPath")
    if reject < 0 or fallback < 0 or reject > fallback:
        raise ValueError(f"{variant} PlayerView does not reject raw defaults before fallback")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--root", type=pathlib.Path, required=True)
    parser.add_argument("--gamelibs-root", type=pathlib.Path, required=True)
    args = parser.parse_args()

    try:
        verify_patch_lists(args.root.resolve())
        for variant in ("game", "mpgame"):
            verify_variant(args.gamelibs_root.resolve(), variant)
    except (OSError, UnicodeError, ValueError) as error:
        raise SystemExit(f"damage-blob-material-fallback: {error}") from error
    print("Damage-blob material fallback contract passed.")


if __name__ == "__main__":
    main()
