#!/usr/bin/env python3
"""
cromakga3d - Third-party SDK builds

Builds the dependencies that have no usable official package for a target, and installs them to
_out/{platform}/{name}-{version}/ so the path states which build produced the artifact.

    python __build_sdk.py win-x64
    python __build_sdk.py android-arm64
    python __build_sdk.py android-x86_64
    python __build_sdk.py wasm_mt
    python __build_sdk.py wasm_mt box3d-0.1.0        # trailing names filter the package set

Only the game binary is out of scope: that stays MSBuild + Visual Studio.

What each platform gets from where:
  win-x64      SDL3/SDL3_ttf from the official -VC package; everything else built here, Debug too
  android-*    SDL3/SDL3_ttf from the official .aar (prefab); everything else built here
  wasm_mt      nothing official exists, so SDL3/SDL3_ttf are built here too; no network stack
               (crNet_wasm.cpp uses emscripten fetch/websocket, so curl/lws/mbedTLS never apply)

The shipped Android ABIs are arm64-v8a and x86_64, the latter being what Google Play Games on PC
runs natively. armeabi-v7a is excluded from the determinism contract because AArch32 Advanced SIMD
always flushes denormals to zero, which violates IEEE-754.
"""

import os
import re
import sys
import shutil
import datetime
import subprocess
import multiprocessing

# ── Config ───────────────────────────────────────────────────────
SDK_ROOT    = r"C:\workspace\sdk"
NDK_DIR     = r"C:\Users\maile\AppData\Local\Android\Sdk\ndk\27.3.13750724"   # <- NDK 설치 경로에 맞게 수정
EMSDK_DIR   = r"C:\workspace\sdk\emsdk"                                      # <- emsdk 설치 경로에 맞게 수정
ANDROID_ABIS = {                                # platform name -> ABI; _out/{platform}/ uses the key
    "android-arm64":  "arm64-v8a",
    "android-x86_64": "x86_64",
}
ANDROID_API = 24                                # SDL3 .aar 은 minSdk 21, SDL3_ttf 는 19 — 24 로 충족

# Package folder names carry their version, matching the layout contract.
BOX3D   = "box3d-0.1.0"
KTX     = "KTX-Software-4.4.2"
MBEDTLS = "mbedtls-3.6.7"
CURL    = "curl-8.21.0"
LWS     = "lws-4.5-stable"
SDL3    = "SDL3-3.4.14"
SDL3TTF = "SDL3_ttf-3.2.2"

# Source tree names differ from the install names for these two.
LWS_SRC     = "libwebsockets-4.5-stable"
SDL3_SRC    = "SDL3-3.4.14"
SDL3TTF_SRC = "SDL3_ttf-3.2.2"

# ── Paths ────────────────────────────────────────────────────────
def cmpath(*parts):
    """Join a path for CMake consumption. Forward slashes are not cosmetic here: CMake re-emits
    these values into generated try_compile projects, and a Windows backslash reads as an escape
    sequence there — lws's atomic check dies on 'Invalid character escape' otherwise."""
    return os.path.join(*parts).replace("\\", "/")

PROJECT_DIR   = os.path.dirname(os.path.abspath(__file__))
LOG_ROOT      = os.path.join(PROJECT_DIR, "logs", "build_sdk")

OUT_ROOT      = cmpath(SDK_ROOT, "_out")
WORK_ROOT     = cmpath(SDK_ROOT, "_build")
NDK_TOOLCHAIN = cmpath(NDK_DIR, "build", "cmake", "android.toolchain.cmake")
NDK_OBJDUMP   = os.path.join(NDK_DIR, "toolchains", "llvm", "prebuilt", "windows-x86_64", "bin", "llvm-objdump.exe")
EMSDK_BAT     = os.path.join(EMSDK_DIR, "emsdk_env.bat")

# ── ANSI colors (Windows 10+ terminal) ───────────────────────────
os.system("")   # enable VT100 on Windows

CYAN   = "\033[96m"
GREEN  = "\033[92m"
RED    = "\033[91m"
YELLOW = "\033[93m"
GRAY   = "\033[90m"
RESET  = "\033[0m"

ANSI = re.compile(r"\033\[[0-9;]*m")

LOG_FILE = None

def open_log(platform):
    global LOG_FILE
    os.makedirs(LOG_ROOT, exist_ok=True)
    stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
    LOG_FILE = open(os.path.join(LOG_ROOT, platform + "_" + stamp + ".log"), "w", encoding="utf-8")
    return LOG_FILE.name


def emit(text):
    """Everything the run prints goes to the terminal and to the log, minus the colors.

    The console is cp949 here while build output is arbitrary, so the terminal write is allowed to
    degrade. The log is UTF-8 and always gets the real text."""
    try:
        print(text)
    except UnicodeEncodeError:
        encoding = sys.stdout.encoding or "utf-8"
        print(text.encode(encoding, "replace").decode(encoding))
    if LOG_FILE:
        LOG_FILE.write(ANSI.sub("", text) + "\n")
        LOG_FILE.flush()   # a build that dies mid-way still leaves its log behind


def col(color, text):
    return color + text + RESET

def print_step(text):
    emit("")
    emit(col(CYAN, "  " + text))

def print_ok(text):
    emit(col(GREEN, "  o  " + text))

def print_warn(text):
    emit(col(YELLOW, "  !  " + text))

def print_fail(text):
    emit(col(RED, "  x  " + text))
    sys.exit(1)


def run(command):
    """Run a build step, streaming its output to both the terminal and the log."""
    process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                               text=True, bufsize=1, errors="replace")
    for line in process.stdout:
        emit(line.rstrip("\n"))
    process.wait()
    return process.returncode


# ── Toolchain environments ───────────────────────────────────────
def absorb_env(batch):
    """Run a toolchain setup batch file, then pull its environment into this process.

    The path goes in as its own argument rather than inside one command string: quoting a path with
    spaces through `cmd /c "..."` mangles it, and the batch then never runs — `set` still succeeds,
    so the failure is silent and only surfaces later as a missing compiler."""
    result = subprocess.run(["cmd", "/c", "call", batch, "&&", "set"], capture_output=True, text=True)
    count = 0
    for line in result.stdout.splitlines():
        key, sep, value = line.partition("=")
        if sep and key and " " not in key:   # skips the banner lines the batch files print
            os.environ[key] = value
            count += 1
    return count


def load_emsdk_env():
    print_step("[env] emsdk")
    if not os.path.exists(EMSDK_BAT):
        print_fail("emsdk_env.bat not found, fix EMSDK_DIR: " + EMSDK_BAT)
    absorb_env(EMSDK_BAT)
    if shutil.which("emcc") is None:
        print_fail("emcc not on PATH after activating emsdk")
    print_ok("emsdk activated")


def load_msvc_env():
    print_step("[env] MSVC")
    vswhere = os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
                           "Microsoft Visual Studio", "Installer", "vswhere.exe")
    if not os.path.exists(vswhere):
        print_fail("vswhere.exe not found: " + vswhere)

    result = subprocess.run([vswhere, "-latest", "-products", "*",
                             "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
                             "-property", "installationPath"], capture_output=True, text=True)
    install = result.stdout.strip().splitlines()
    if not install:
        print_fail("no Visual Studio install with the x64 C++ toolset")

    vcvars = os.path.join(install[0], "VC", "Auxiliary", "Build", "vcvars64.bat")
    if not os.path.exists(vcvars):
        print_fail("vcvars64.bat not found: " + vcvars)

    absorb_env(vcvars)
    if shutil.which("cl") is None:
        print_fail("cl.exe not on PATH after running vcvars64")
    print_ok("MSVC activated: " + install[0])


# ── Build core ───────────────────────────────────────────────────
def platform_cflags(platform):
    """Flags every package on this platform compiles with. Kept separate from per-package flags so
    the two can be merged — both want CMAKE_C_FLAGS, and a second -D would silently drop the first."""
    if platform == "wasm_mt":
        # must match the app: wasm-ld rejects an atomics mismatch between archive and consumer
        return "-pthread"
    if platform == "win-x64":
        # C4819 under a Korean locale is what makes lws's hardcoded /WX fatal; fixing the charset
        # removes the warning instead of patching the source tree
        return "/utf-8"
    return ""


def platform_flags(platform):
    if platform in ANDROID_ABIS:
        return [
            "-DCMAKE_TOOLCHAIN_FILE=" + NDK_TOOLCHAIN,
            "-DANDROID_ABI=" + ANDROID_ABIS[platform],
            "-DANDROID_PLATFORM=android-" + str(ANDROID_API),
        ]
    if platform == "win-x64":
        return ["-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded$<$<CONFIG:Debug>:Debug>"]
    return []


def cmake_build(platform, name, source, options, only, config="Release", suffix="", cflags=""):
    """configure + build + install one package into _out/{platform}/{name}{suffix}/."""
    if only and name not in only:
        return

    target = name + suffix
    prefix = cmpath(OUT_ROOT, platform, target)
    # box3d installs both configs into one prefix, so the build dirs must still differ by config
    build  = cmpath(WORK_ROOT, platform, target if config == "Release" else target + "-" + config.lower())

    print_step("[" + platform + "] " + target + "  (" + config + ")")

    if not os.path.isdir(source):
        print_fail("source not found: " + source)

    # A reconfigure that passes only some flags silently reverts the rest to their defaults — the
    # KTX build turns into a shared library that way. Always configure a clean dir in one shot.
    if os.path.exists(build):
        shutil.rmtree(build)

    merged = (platform_cflags(platform) + " " + cflags).strip()

    common = [
        "-DCMAKE_BUILD_TYPE=" + config,
        "-DCMAKE_INSTALL_PREFIX=" + prefix,
        "-DBUILD_SHARED_LIBS=OFF",
    ] + platform_flags(platform)

    if merged:
        common += ["-DCMAKE_C_FLAGS=" + merged, "-DCMAKE_CXX_FLAGS=" + merged]

    # emcmake supplies the Emscripten toolchain file and the node cross-compiling emulator; the
    # build and install steps afterwards are plain cmake
    launcher = ["cmd", "/c", "emcmake", "cmake"] if platform == "wasm_mt" else ["cmake"]

    emit(col(GRAY, "     configure"))
    if run(launcher + ["-S", source, "-B", build, "-G", "Ninja"] + common + options) != 0:
        print_fail(target + ": configure failed")

    emit(col(GRAY, "     build"))
    build_cmd = ["cmake", "--build", build, "-j", str(multiprocessing.cpu_count())]
    if platform == "wasm_mt" and name == KTX:
        # the js binding targets hardcode C++11 and do not compile under emsdk, so build only the core
        build_cmd += ["--target", "ktx", "ktx_read"]
    if run(build_cmd) != 0:
        print_fail(target + ": build failed")

    emit(col(GRAY, "     install"))
    install_cmd = ["cmake", "--install", build]
    if platform == "wasm_mt" and name == KTX:
        # the same if(EMSCRIPTEN) block that adds the js binding targets also adds install rules for
        # libktx.js, and those rules run even though --target ktx never built them. The core lib,
        # its headers and the cmake config all live in the dev component, so ask for that alone.
        install_cmd += ["--component", "dev"]
    if run(install_cmd) != 0:
        print_fail(target + ": install failed")

    if name == KTX:
        # KTX_INSTALL_TARGETS lists ktx alone, so the read-only archive the build already produced
        # never reaches the prefix. That one is what we link: it drops the basisu encoder, and
        # nothing in crTextureLoader writes a ktx2. astcenc still has to be linked either way —
        # lib/astc_codec.cpp decompresses through it and is in both targets.
        archive = "ktx_read.lib" if platform == "win-x64" else "libktx_read.a"
        built   = os.path.join(build, archive)
        if not os.path.exists(built):
            print_fail("ktx_read not built: " + built)
        shutil.copy2(built, os.path.join(prefix, "lib", archive))

    print_ok(target + " -> " + prefix)


# ── Artifact checks ──────────────────────────────────────────────
# box3d is the only dependency on the simulation path, so it is the only one whose float semantics
# are checked. The others only have to link.

def _disassemble(tool, archive):
    if not os.path.exists(tool):
        print_warn("disassembler not found, CHECK SKIPPED: " + tool)
        return None
    if not os.path.exists(archive):
        print_fail("archive not found: " + archive)
    result = subprocess.run([tool, "-d", archive], capture_output=True, text=True)
    if result.returncode != 0:
        print_warn("disassembly failed, CHECK SKIPPED: " + archive)
        return None
    return result.stdout


def _find_mnemonics(disasm, mnemonics):
    hits = []
    for line in disasm.splitlines():
        text = line.strip().lower()
        for op in mnemonics:
            if "\t" + op in text or " " + op + " " in text or text.endswith(" " + op):
                hits.append(line.strip())
                break
    return hits


def check_box3d(platform, archive):
    """contact_solver.c writes its mul-add unfused on purpose ("Cannot use real FMA because it
    doesn't match the non-SIMD path"). A compiler that fuses it back moves that platform off the
    shared baseline, and nothing at runtime would report it."""
    print_step("[artifact check] box3d — " + platform)

    if platform == "wasm_mt":
        # the target_features custom section stores feature names verbatim, so the archive bytes
        # answer this without a tool. relaxed-simd is the dangerous one: f32x4.relaxed_madd may or
        # may not fuse depending on the browser, so it must never be enabled.
        data = open(archive, "rb").read()
        if b"relaxed-simd" in data:
            print_fail("relaxed-simd present — fusion becomes browser-dependent")
        print_ok("relaxed-simd absent")
        if b"atomics" not in data:
            print_warn("atomics feature not found; if this is wrong wasm-ld will reject the link")
        return

    if platform == "android-arm64":
        disasm = _disassemble(NDK_OBJDUMP, archive)
        mnemonics = ("fmla", "fmls", "fmadd", "fmsub", "fnmadd", "fnmsub")
    else:
        disasm = _disassemble(NDK_OBJDUMP, archive)
        mnemonics = ("vfmadd", "vfmsub", "vfnmadd", "vfnmsub")

    if disasm is None:
        return

    hits = _find_mnemonics(disasm, mnemonics)
    if hits:
        print_fail("fused multiply-add found (" + str(len(hits)) + "):\n       " + "\n       ".join(hits[:8]))
    print_ok("no fused multiply-add")

    if platform == "win-x64":
        # the project links /MT throughout; a dependency built against the DLL CRT would drag in a
        # second heap and only fail at link or, worse, at runtime
        data = open(archive, "rb").read()
        if b"MSVCRT" in data:
            print_fail("MSVCRT reference found — dependency was built against the DLL CRT, expected /MT")
        print_ok("CRT is static (/MT)")


# ── Package option sets ──────────────────────────────────────────
def box3d_options():
    return [
        "-DBOX3D_SAMPLES=OFF",
        "-DBOX3D_UNIT_TESTS=OFF",
        "-DBOX3D_BENCHMARKS=OFF",
        "-DBOX3D_DOCS=OFF",
        # no-op in Release: B3_VALIDATE expands to B3_ASSERT, which NDEBUG compiles out
        "-DBOX3D_VALIDATE=OFF",
        "-DBOX3D_DISABLE_SIMD=OFF",
        "-DBOX3D_DOUBLE_PRECISION=OFF",
    ]


def ktx_options(isa_none):
    # KTX_FEATURE_KTX1 must stay ON — texture.c references ktxTexture1_constructFromStreamAndHeader
    # unguarded, so OFF breaks static consumers at link time.
    #
    # The astcenc ISA decides the artifact's filename, and the build files link it by name. wasm_mt
    # and Android share one CMakeLists, so both are forced to astcenc-none-static; win-x64 is named
    # separately in the vcxproj and keeps the avx2 default it already links. The choice is free
    # either way at runtime — our textures transcode through basisu, not astcenc.
    return ([] if not isa_none else ["-DASTCENC_ISA_NONE=ON"]) + [
        "-DKTX_FEATURE_KTX1=ON",
        "-DKTX_FEATURE_KTX2=ON",
        "-DKTX_FEATURE_GL_UPLOAD=ON",
        "-DKTX_FEATURE_VK_UPLOAD=OFF",
        "-DKTX_FEATURE_TESTS=OFF",
        "-DKTX_FEATURE_TOOLS=OFF",
        "-DKTX_FEATURE_DOC=OFF",
        "-DKTX_FEATURE_JNI=OFF",
        "-DKTX_FEATURE_PY=OFF",
        "-DKTX_FEATURE_ETC_UNPACK=ON",
    ]


def mbedtls_options(static_crt):
    options = [
        "-DENABLE_PROGRAMS=OFF",
        "-DENABLE_TESTING=OFF",
        "-DMBEDTLS_FATAL_WARNINGS=OFF",
        "-DUSE_STATIC_MBEDTLS_LIBRARY=ON",
        "-DUSE_SHARED_MBEDTLS_LIBRARY=OFF",
    ]
    if static_crt:
        options.append("-DMSVC_STATIC_RUNTIME=ON")
    return options


def curl_options(mbedtls_prefix, static_crt):
    # HTTP_ONLY matches what win-x64 has shipped since crNet was verified; websockets are lws's job.
    # No CA store probing either: crNet ships crassets/certs/cacert.pem and points curl at it.
    #
    # mbedTLS is handed over as absolute paths rather than a prefix, because the NDK toolchain
    # restricts find_library to the sysroot and would never locate an artifact in _out. Presetting
    # curl's four input variables makes those find_library calls no-ops. Note the singular
    # MBEDTLS_INCLUDE_DIR — passing the deprecated plural sets the singular anyway, which suppresses
    # the CMake-config path and drops it into exactly the find_library branch that cannot work.
    options = [
        "-DBUILD_CURL_EXE=OFF",
        "-DBUILD_STATIC_LIBS=ON",
        "-DHTTP_ONLY=ON",
        "-DCURL_ENABLE_SSL=ON",
        "-DCURL_USE_MBEDTLS=ON",
        "-DCURL_USE_OPENSSL=OFF",
        "-DCURL_USE_SCHANNEL=OFF",
        "-DCURL_USE_LIBSSH2=OFF",
        "-DCURL_USE_LIBPSL=OFF",
        "-DUSE_LIBIDN2=OFF",
        "-DCURL_ZLIB=OFF",
        "-DCURL_BROTLI=OFF",
        "-DCURL_ZSTD=OFF",
        "-DCURL_DISABLE_LDAP=ON",
        "-DCURL_DISABLE_LDAPS=ON",
        "-DCURL_CA_NATIVE=OFF",
        "-DCURL_CA_BUNDLE=none",
        "-DCURL_CA_PATH=none",
        "-DMBEDTLS_USE_STATIC_LIBS=ON",
        "-DMBEDTLS_INCLUDE_DIR=" + cmpath(mbedtls_prefix, "include"),
        "-DMBEDTLS_LIBRARY=" + cmpath(mbedtls_prefix, "lib", lib_name("mbedtls", static_crt)),
        "-DMBEDX509_LIBRARY=" + cmpath(mbedtls_prefix, "lib", lib_name("mbedx509", static_crt)),
        "-DMBEDCRYPTO_LIBRARY=" + cmpath(mbedtls_prefix, "lib", lib_name("mbedcrypto", static_crt)),
    ]
    if static_crt:
        options.append("-DCURL_STATIC_CRT=ON")
    return options


def lws_options(mbedtls_prefix, static_crt):
    # Client only, on every platform. The browser WebSocket API cannot listen or accept, so wasm is
    # client-only by construction; matching the native builds to it keeps crNet's capability surface
    # identical everywhere rather than larger on two platforms than the third can ever be.
    #
    # lws takes the LWS_-prefixed pair as its input and derives MBEDTLS_LIBRARIES /
    # MBEDTLS_INCLUDE_DIRS from them (lib/tls/mbedtls/CMakeLists.txt). Leaving either undefined
    # trips _WANT_MBT, which falls back to find_library. The two singular vars below are separate —
    # lws feeds those to its own try_compile checks.
    libs = [cmpath(mbedtls_prefix, "lib", lib_name(n, static_crt)) for n in ("mbedtls", "mbedx509", "mbedcrypto")]
    if static_crt:
        # mbedTLS entropy calls BCryptGenRandom, which lws's feature-detection link needs resolved
        libs.append("bcrypt.lib")

    options = [
        "-DLWS_WITH_SSL=ON",
        "-DLWS_WITH_MBEDTLS=ON",
        "-DLWS_WITH_STATIC=ON",
        "-DLWS_WITH_SHARED=OFF",
        "-DLWS_WITHOUT_SERVER=ON",
        "-DLWS_WITH_SECURE_STREAMS=OFF",
        "-DLWS_WITH_HTTP2=OFF",
        "-DLWS_WITHOUT_TESTAPPS=ON",
        "-DLWS_WITHOUT_TEST_SERVER=ON",
        "-DLWS_WITHOUT_TEST_SERVER_EXTPOLL=ON",
        "-DLWS_WITHOUT_TEST_PING=ON",
        "-DLWS_WITHOUT_TEST_CLIENT=ON",
        "-DLWS_WITH_MINIMAL_EXAMPLES=OFF",
        "-DLWS_WITH_ZLIB=OFF",
        "-DLWS_MBEDTLS_INCLUDE_DIRS=" + cmpath(mbedtls_prefix, "include"),
        "-DLWS_MBEDTLS_LIBRARIES=" + ";".join(libs),
        "-DMBEDX509_LIBRARY=" + libs[1],
        "-DMBEDCRYPTO_LIBRARY=" + libs[2],
    ]
    if static_crt:
        options.append("-DLWS_MSVC_STATIC_RUNTIME=ON")
    else:
        options.append("-DLWS_WITH_NETLINK=OFF")
    return options


def lib_name(stem, static_crt):
    return stem + ".lib" if static_crt else "lib" + stem + ".a"


def src(name):
    return cmpath(SDK_ROOT, name)


# ── Platforms ────────────────────────────────────────────────────
def build_win(only):
    load_msvc_env()

    # box3d is the one package whose Debug and Release share an install prefix: its CMake appends a
    # 'd' to the debug library, so box3d.lib and box3dd.lib sit side by side. sdk.props depends on
    # that shape — the other four use a -debug prefix instead.
    for config, suffix in (("Release", ""), ("Debug", "")):
        cmake_build("win-x64", BOX3D, src(BOX3D), box3d_options(), only, config, suffix,
                    cflags="/fp:precise")

    if not only or BOX3D in only:
        check_box3d("win-x64", os.path.join(OUT_ROOT, "win-x64", BOX3D, "lib", "box3d.lib"))

    for config, suffix in (("Release", ""), ("Debug", "-debug")):
        cmake_build("win-x64", KTX, src(KTX), ktx_options(False), only, config, suffix)
        cmake_build("win-x64", MBEDTLS, src(MBEDTLS), mbedtls_options(True), only, config, suffix)

        mbedtls_prefix = cmpath(OUT_ROOT, "win-x64", MBEDTLS + suffix)
        cmake_build("win-x64", CURL, src(CURL), curl_options(mbedtls_prefix, True), only, config, suffix)
        cmake_build("win-x64", LWS, src(LWS_SRC), lws_options(mbedtls_prefix, True), only, config, suffix)


def build_android(platform, only):
    # box3d ships Debug alongside Release, the same way win-x64 does. B3_ASSERT lives in a public
    # header and calls b3InternalAssert, which a Release build compiles out — so an app built
    # without NDEBUG references a symbol the Release archive does not carry. CMake's DEBUG_POSTFIX
    # puts them side by side as libbox3d.a and libbox3dd.a. None of the other dependencies inline
    # anything into our translation units, so Release alone is enough for them.
    for config in ("Release", "Debug"):
        cmake_build(platform, BOX3D, src(BOX3D), box3d_options(), only, config,
                    cflags="-ffp-contract=off")

    if not only or BOX3D in only:
        check_box3d(platform, os.path.join(OUT_ROOT, platform, BOX3D, "lib", "libbox3d.a"))

    cmake_build(platform, KTX, src(KTX), ktx_options(True), only)
    cmake_build(platform, MBEDTLS, src(MBEDTLS), mbedtls_options(False), only)

    mbedtls_prefix = cmpath(OUT_ROOT, platform, MBEDTLS)
    cmake_build(platform, CURL, src(CURL), curl_options(mbedtls_prefix, False), only)
    cmake_build(platform, LWS, src(LWS_SRC), lws_options(mbedtls_prefix, False), only)


def require_sdl_ttf_submodules():
    """SDL3_ttf vendors freetype/harfbuzz/plutosvg/plutovg as git submodules, and the release
    archive ships only the scripts that fetch them. wasm is the only target that compiles SDL3_ttf
    at all — Android takes the .aar and Windows the -VC package — so this is the one place it bites,
    and it bites again on every version bump."""
    external = os.path.join(SDK_ROOT, SDL3TTF_SRC, "external")
    missing = [name for name in ("freetype", "harfbuzz", "plutosvg", "plutovg")
               if not os.path.exists(os.path.join(external, name, "CMakeLists.txt"))]
    if not missing:
        return

    print_fail("SDL3_ttf vendored submodules missing: " + ", ".join(missing) + "\n"
               "       " + external + " 에서 아래 중 하나를 1회 실행하세요:\n"
               "         powershell -ExecutionPolicy Bypass -File " + os.path.join(external, "Get-GitModules.ps1") + "\n"
               "         (Git Bash) cd " + external.replace("\\", "/") + " && ./download.sh")


def build_wasm(only):
    load_emsdk_env()

    if not only or SDL3TTF in only:
        require_sdl_ttf_submodules()

    # the app compiles with -ffp-contract=off and so must box3d. wasm has no FMA instruction unless
    # relaxed-simd is on, so this is expected to change nothing — the artifact check below is what
    # turns that expectation into a fact.
    cmake_build("wasm_mt", BOX3D, src(BOX3D), box3d_options(), only,
                cflags="-ffp-contract=off")

    if not only or BOX3D in only:
        check_box3d("wasm_mt", os.path.join(OUT_ROOT, "wasm_mt", BOX3D, "lib", "libbox3d.a"))

    cmake_build("wasm_mt", KTX, src(KTX), ktx_options(True), only)

    cmake_build("wasm_mt", SDL3, src(SDL3_SRC), [
        "-DSDL_SHARED=OFF",
        "-DSDL_STATIC=ON",
        "-DSDL_TEST_LIBRARY=OFF",
        "-DSDL_TESTS=OFF",
        "-DSDL_EXAMPLES=OFF",
    ], only)

    # SDL3_ttf does find_package(SDL3 REQUIRED), so it has to be pointed at the SDL3 we just
    # installed. SDL3_DIR names the config directory outright rather than going through
    # CMAKE_PREFIX_PATH: the Emscripten toolchain sets CMAKE_FIND_ROOT_PATH_MODE_PACKAGE to ONLY,
    # which confines package search to the emscripten sysroot and ignores a prefix under _out.
    #
    # Vendored third-party libs keep the build off emscripten ports, which drift between emsdk
    # versions; harfbuzz and plutosvg stay at their ON defaults to match the official .aar.
    cmake_build("wasm_mt", SDL3TTF, src(SDL3TTF_SRC), [
        "-DSDL3_DIR=" + cmpath(OUT_ROOT, "wasm_mt", SDL3, "lib", "cmake", "SDL3"),
        "-DSDLTTF_SAMPLES=OFF",
        "-DSDLTTF_VENDORED=ON",
    ], only)


# ── Entry ────────────────────────────────────────────────────────
def report(platform):
    root = os.path.join(OUT_ROOT, platform)
    print_step("산출물")
    for dirpath, _, filenames in os.walk(root):
        for filename in sorted(filenames):
            if filename.endswith(".a") or filename.endswith(".lib"):
                emit(col(GRAY, "     " + os.path.join(dirpath, filename).replace(root + os.sep, "")))


def main():
    platform = sys.argv[1] if len(sys.argv) > 1 else ""
    only     = sys.argv[2:]

    if platform in ("win-x64", "wasm_mt") or platform in ANDROID_ABIS:
        open_log(platform)

    if platform == "win-x64":
        build_win(only)
    elif platform in ANDROID_ABIS:
        if not os.path.exists(NDK_TOOLCHAIN):
            print_fail("NDK toolchain not found, fix NDK_DIR: " + NDK_TOOLCHAIN)
        build_android(platform, only)
    elif platform == "wasm_mt":
        build_wasm(only)
    else:
        print("usage: python __build_sdk.py [win-x64|android-arm64|android-x86_64|wasm_mt] [package ...]")
        print("       package names filter the build, e.g.  " + CURL + " " + LWS)
        sys.exit(1)

    report(platform)
    print_ok("완료. 로그: " + os.path.relpath(LOG_FILE.name, PROJECT_DIR))
    LOG_FILE.close()


if __name__ == "__main__":
    main()
