//! Builds and links SuperCollider's `libscsynth` and generates Rust bindings over the engine's
//! `extern "C"` surface.
//!
//! Two very different builds share one binding surface:
//!
//! * NATIVE ([`build_native`]): CMake builds a static `libscsynth` (with our host-pumped
//!   `AUDIOAPI=external` driver) against the system C/C++ toolchain, libsndfile and fftw.
//! * WASM ([`build_wasm`]): there is no system toolchain or libsndfile/fftw, so the engine's DSP
//!   core + static UGen plugins are compiled FROM SOURCE to `wasm32-unknown-unknown` against a
//!   from-source musl + libc++, driverless (`mRealTime=false`) via `csrc/SC_WasmPump.cpp`. Mirrors
//!   SuperCollider's own emscripten/webaudio build.
//!
//! Both paths resolve the SuperCollider source (env var or fetched at the `source.toml` revision;
//! see [`supercollider_source`]), copy it into `OUT_DIR` and patch the copy, and both generate
//! `bindings.rs` from `SC_WorldOptions.h` with the same allowlist so `crates/scsynth` sees one API.

use std::env;
use std::fs;
use std::path::{Path, PathBuf};

fn main() {
    // The wasm engine is compiled from source against the from-source musl/libc++ (no CMake,
    // libsndfile or fftw exist for this target); the native engine uses CMake + system libs.
    if env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("wasm32") {
        build_wasm();
    } else {
        build_native();
    }
}

/// Locate the SuperCollider source tree (asserted present).
///
/// `SCSYNTH_SYS_SUPERCOLLIDER_DIR` (set by the Nix build and the dev shell) takes precedence and
/// never fetches. Otherwise the revision pinned in `source.toml` is git-fetched into a cache (see
/// [`fetch_pinned_source`]) - so the published crate ships no vendored source yet the build still
/// gets the exact pinned tree.
fn supercollider_source() -> PathBuf {
    let dir = resolve_pinned_source("SCSYNTH_SYS_SUPERCOLLIDER_DIR", "supercollider", true);
    assert!(
        dir.join("CMakeLists.txt").is_file(),
        "SuperCollider source missing/incomplete at {} - set SCSYNTH_SYS_SUPERCOLLIDER_DIR to a \
         checkout, or allow the build to fetch it (needs `git` + network)",
        dir.display(),
    );
    dir
}

// ---------------------------------------------------------------------------
// Native build (CMake + system libsndfile/fftw + the host-pumped external driver).
// ---------------------------------------------------------------------------

fn build_native() {
    let sc_src = supercollider_source();

    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
    let sc = out_dir.join("sc-src");

    // Copy the pinned source into a writable tree (once) and apply the AUDIOAPI=external patch.
    if !sc.join("CMakeLists.txt").is_file() {
        copy_tree(&sc_src, &sc);
        patch_audioapi_external(&sc);
    }
    // Keep our driver source in sync (only rewrite when it changes, to avoid needless rebuilds).
    sync_file(
        &PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("csrc/SC_ExternalDriver.cpp"),
        &sc.join("server/scsynth/SC_ExternalDriver.cpp"),
    );

    let inc_server = sc.join("include/server");
    let inc_common = sc.join("include/common");
    let inc_plugin = sc.join("include/plugin_interface");
    let inc_common_dir = sc.join("common");

    // --- 1. Build a static `libscsynth` (static plugins, host-pumped external driver). ---
    let dst = cmake::Config::new(&sc)
        .define("CMAKE_BUILD_TYPE", "Release")
        .define("LIBSCSYNTH", "OFF")
        .define("STATIC_PLUGINS", "ON")
        .define("SUPERNOVA", "OFF")
        .define("SC_QT", "OFF")
        .define("SC_IDE", "OFF")
        .define("SCLANG_SERVER", "OFF")
        .define("NO_X11", "ON")
        .define("NO_AVAHI", "ON")
        .define("SC_HIDAPI", "OFF")
        .define("SC_ABLETON_LINK", "OFF")
        .define("ENABLE_TESTSUITE", "OFF")
        .define("INSTALL_HELP", "OFF")
        .define("AUDIOAPI", "external")
        .build_target("libscsynth")
        .build();
    let build_dir = dst.join("build");

    // --- 2. Link the static libraries CMake produced (consumers before providers). ---
    let scsynth_a = find_file(&build_dir, "libscsynth.a")
        .unwrap_or_else(|| panic!("libscsynth.a not found under {}", build_dir.display()));
    let tlsf_a = find_file(&build_dir, "libtlsf.a")
        .unwrap_or_else(|| panic!("libtlsf.a not found under {}", build_dir.display()));
    link_search(scsynth_a.parent().unwrap());
    link_search(tlsf_a.parent().unwrap());
    println!("cargo:rustc-link-lib=static=scsynth");
    println!("cargo:rustc-link-lib=static=tlsf");

    // --- 3. Compile the shim (`WorldOptions` default + `ReplyAddress` accessor; a C++ consumer of
    // libstdc++). `SC_ReplyImpl.hpp` pulls `boost/asio.hpp` on native, so add the boost include dirs.
    cc::Build::new()
        .cpp(true)
        .std("c++17")
        .include(&inc_server)
        .include(&inc_common)
        .include(&inc_plugin)
        .include(&inc_common_dir)
        .include(sc.join("external_libraries/boost"))
        .include(sc.join("external_libraries/boost_sync/include"))
        .define("SC_MEMORY_ALIGNMENT", "32")
        .file("csrc/shim.cpp")
        .compile("scsynth_shim");

    // --- 4. System libraries `libscsynth` references (providers, after the static libs). ---
    pkg_config::probe_library("sndfile").expect("libsndfile (sndfile.pc)");
    pkg_config::probe_library("fftw3f").expect("fftw3f (fftw3f.pc)");
    for lib in ["pthread", "dl", "rt", "m"] {
        println!("cargo:rustc-link-lib=dylib={lib}");
    }
    // The C++ runtime must come last so it satisfies the static C++ archives above.
    println!("cargo:rustc-link-lib=dylib=stdc++");

    // --- 5. Generate bindings over the engine's `extern "C"` API. ---
    let bindings = bindgen::Builder::default()
        .header(inc_server.join("SC_WorldOptions.h").to_string_lossy())
        .clang_args(["-x", "c++", "-std=c++17"])
        .clang_arg(format!("-I{}", inc_server.display()))
        .clang_arg(format!("-I{}", inc_common.display()))
        .clang_arg(format!("-I{}", inc_plugin.display()))
        .clang_arg(format!("-I{}", inc_common_dir.display()))
        .clang_arg("-DSC_MEMORY_ALIGNMENT=32");
    write_bindings(allowlist(bindings));

    println!("cargo:rerun-if-changed=csrc/shim.cpp");
    println!("cargo:rerun-if-changed=csrc/SC_ExternalDriver.cpp");
    println!("cargo:rerun-if-changed=build.rs");
}

// ---------------------------------------------------------------------------
// Wasm build (from-source compile, driverless `mRealTime=false`, from-source musl + libc++).
//
// The vendored SuperCollider submodule is kept PRISTINE: sources are copied into `OUT_DIR/sc` and a
// handful of `#ifndef SC_WASM` guards are applied to the COPIES (see `patch_sc_sources`), each
// `assert!`-guarded so an upstream layout change fails loudly.
// ---------------------------------------------------------------------------

fn build_wasm() {
    let sc_src = supercollider_source()
        .canonicalize()
        .expect("SuperCollider source not found");
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());

    // Copy the pinned source into a writable scratch tree (once) and apply the SC_WASM guards.
    let sc = out_dir.join("sc");
    if !sc.join("server/scsynth/SC_World.cpp").is_file() {
        copy_tree(&sc_src, &sc);
        patch_sc_sources(&sc);
        generate_version_header(&sc);
    }

    // --- Toolchain include dirs exported by the from-source musl + libc++ deps. ---
    let libc_includes: Vec<String> = env::var("DEP_WASM32_LIBC_INCLUDE")
        .unwrap_or_default()
        .split(':')
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .collect();
    let libcxx_include = env::var("DEP_WASM32_LIBCXX_INCLUDE").unwrap_or_default();
    assert!(
        !libc_includes.is_empty() && !libcxx_include.is_empty(),
        "DEP_WASM32_LIBC_INCLUDE / DEP_WASM32_LIBCXX_INCLUDE not set - the from-source libc/libc++ \
         deps must export them via their own build scripts",
    );

    // --- SuperCollider include roots. ---
    let inc = [
        sc.join("include/server"),
        sc.join("include/common"),
        sc.join("include/plugin_interface"),
        sc.join("common"),
        sc.join("server/scsynth"),
        sc.join("server/plugins"),
        sc.join("external_libraries/boost"),
        sc.join("external_libraries/boost_sync/include"),
        sc.join("external_libraries/nova-simd"),
        sc.join("external_libraries/nova-tt"),
        sc.join("external_libraries/TLSF-2.4.6/src"),
    ];

    // --- The scsynth engine + plugin sources (relative to the copied tree). ---
    let core = [
        "server/scsynth/SC_BufGen.cpp",
        "server/scsynth/SC_Graph.cpp",
        "server/scsynth/SC_GraphDef.cpp",
        "server/scsynth/SC_Group.cpp",
        "server/scsynth/SC_Lib_Cintf.cpp",
        "server/scsynth/SC_Lib.cpp",
        "server/scsynth/SC_MiscCmds.cpp",
        "server/scsynth/SC_Node.cpp",
        "server/scsynth/SC_Rate.cpp",
        "server/scsynth/SC_SequencedCommand.cpp",
        "server/scsynth/SC_Str4.cpp",
        "server/scsynth/SC_Unit.cpp",
        "server/scsynth/SC_UnitDef.cpp",
        "server/scsynth/SC_World.cpp",
        "server/scsynth/SC_CoreAudio.cpp",
    ];
    // SC_Reply.cpp is intentionally EXCLUDED, mirroring SuperCollider's own webaudio build
    // (server/scsynth/CMakeLists.txt REMOVE_ITEMs it): its ReplyAddress operators dereference the
    // boost::asio `mAddress` field, which we drop on wasm. The pump shim provides those symbols.
    let common = [
        "common/SC_fftlib.cpp",
        "common/SC_AllocPool.cpp",
        "common/SC_Errors.cpp",
        "common/SC_StringBuffer.cpp",
        "common/SC_StringParser.cpp",
        "common/Samp.cpp",
    ];
    // C sources: the green FFT (fftlib.c provides rffts/riffts, included extern "C" by SC_fftlib.cpp)
    // and TLSF (scope_buffer.hpp's pool, referenced by the unused shared-memory creator). These MUST
    // compile as C so their symbols are unmangled to match the extern "C" call sites.
    let c_sources = [
        "common/fftlib.c",
        "external_libraries/TLSF-2.4.6/src/tlsf.c",
    ];
    // Exactly the plugin TUs whose `*_Load` is called by SC_Lib_Cintf.cpp's STATIC_PLUGINS block,
    // plus the three FFT module TUs reached transitively by FFT_UGens_Load (FFTInterfaceTable.cpp ->
    // initFFT/initPV/initPartConv). Derived from server/plugins/CMakeLists.txt. This is the same
    // UGen set the native CMake build registers (which additionally registers DiskIO via libsndfile);
    // UGens SuperCollider ships only as separately-loaded plugins (ChaosUGens, the MIR/analysis set,
    // PV third-party onset detectors, UnpackFFTUGens, DemoUGens) are absent from the static load list
    // and so are unavailable on both targets. See the crate README's "Plugins (UGens)" section.
    let plugins = [
        "server/plugins/IOUGens.cpp",
        "server/plugins/OscUGens.cpp",
        "server/plugins/DelayUGens.cpp",
        "server/plugins/BinaryOpUGens.cpp",
        "server/plugins/FilterUGens.cpp",
        "server/plugins/GendynUGens.cpp",
        "server/plugins/LFUGens.cpp",
        "server/plugins/NoiseUGens.cpp",
        "server/plugins/MulAddUGens.cpp",
        "server/plugins/GrainUGens.cpp",
        "server/plugins/PanUGens.cpp",
        "server/plugins/ReverbUGens.cpp",
        "server/plugins/TriggerUGens.cpp",
        "server/plugins/UnaryOpUGens.cpp",
        "server/plugins/PhysicalModelingUGens.cpp",
        "server/plugins/TestUGens.cpp",
        "server/plugins/DemandUGens.cpp",
        "server/plugins/DynNoiseUGens.cpp",
        "server/plugins/FFTInterfaceTable.cpp",
        "server/plugins/FFT_UGens.cpp",
        "server/plugins/PV_UGens.cpp",
        "server/plugins/PartitionedConvolution.cpp",
    ];

    // --- C gap symbols (aligned alloc, math, pthread/libc shims the minimal libc lacks). ---
    let mut c = cc::Build::new();
    for d in &libc_includes {
        c.include(d);
    }
    // The gap intentionally redeclares libc built-ins (fprintf/vfprintf/fwrite) with our opaque
    // `GapFile` instead of pulling <stdio.h>'s FILE; silence clang's -Wbuiltin-requires-header.
    c.flag("-Wno-builtin-requires-header")
        .file("csrc/libc_gap.c")
        .compile("libc_gap");

    // --- SuperCollider C sources (green FFT + TLSF), compiled as C for unmangled linkage. ---
    let mut sc_c = cc::Build::new();
    sc_c.std("c11")
        .flag("-w")
        .define("SC_FFT_GREEN", None)
        .define("SC_WASM", None)
        .define("NDEBUG", None);
    for d in &libc_includes {
        sc_c.include(d);
    }
    for d in &inc {
        sc_c.include(d);
    }
    for f in &c_sources {
        sc_c.file(sc.join(f));
    }
    sc_c.compile("scsynth_wasm_c");

    // --- The scsynth engine: one big archive built with wasm-native exceptions + RTTI. ---
    let mut sc_build = cc::Build::new();
    sc_build
        .cpp(true)
        .std("c++17")
        .cpp_link_stdlib(None)
        .flag("-fwasm-exceptions")
        .flag("-w") // SC emits many warnings; quiet them during bring-up.
        .define("NO_LIBSNDFILE", None)
        .define("NO_X11", None) // no UI plugins; drops the UIUGens_Unload reference in SC_Lib_Cintf.
        .define("STATIC_PLUGINS", None)
        .define("SC_MEMORY_ALIGNMENT", "32")
        .define("SC_FFT_GREEN", None)
        .define("SC_WASM", None)
        // Pick the webaudio audio-API id so SC_CoreAudio.h compiles without pulling a device backend
        // header. No driver is constructed under mRealTime=false; the pump shim stubs
        // SC_NewAudioDriver (referenced, never called) to keep the link satisfied.
        .define("SC_AUDIO_API", "SC_AUDIO_API_WEBAUDIO")
        .define("NDEBUG", None);
    sc_build.include(&libcxx_include);
    for d in &libc_includes {
        sc_build.include(d);
    }
    for d in &inc {
        sc_build.include(d);
    }
    for f in core.iter().chain(common.iter()).chain(plugins.iter()) {
        sc_build.file(sc.join(f));
    }
    // The driverless pump shim + SC_WebAudio.cpp-style fake implementations, and the `WorldOptions`
    // default-constructor shim (shared with native; consumes libc++ here).
    sc_build.file("csrc/SC_WasmPump.cpp");
    sc_build.file("csrc/shim.cpp");
    sc_build.compile("scsynth_wasm");

    // Force-link the Rust-based libc (malloc/free/etc.) and the from-source libc++.
    println!("cargo::rustc-link-lib=wasm32-libcxx");
    println!("cargo::rustc-link-lib=wasm32-libc");

    // --- Generate bindings over the engine's `extern "C"` API, for the wasm32 target/ABI. ---
    let mut bindings = bindgen::Builder::default()
        .header(
            sc.join("include/server/SC_WorldOptions.h")
                .to_string_lossy(),
        )
        .clang_args(["-x", "c++", "-std=c++17"])
        .clang_arg("-target")
        .clang_arg("wasm32-unknown-unknown")
        .clang_arg("-DSC_MEMORY_ALIGNMENT=32")
        // Match the engine compile so the patched SC_WASM header branches are taken (e.g.
        // SC_Endian.h / SC_ReplyImpl.hpp) and the struct layouts agree with the compiled objects.
        .clang_arg("-DSC_WASM")
        .clang_arg("-DSC_FFT_GREEN")
        .clang_arg("-DSC_AUDIO_API=SC_AUDIO_API_WEBAUDIO");
    for d in &inc {
        bindings = bindings.clang_arg(format!("-I{}", d.display()));
    }
    // libc++ first, then libc: clang resolves system headers (<cstdint>, <cstddef>, ...) the SC
    // headers pull in transitively for the wasm32 target.
    bindings = bindings.clang_arg(format!("-I{libcxx_include}"));
    for d in &libc_includes {
        bindings = bindings.clang_arg(format!("-I{d}"));
    }
    write_bindings(allowlist(bindings));

    println!("cargo::rerun-if-changed=csrc/SC_WasmPump.cpp");
    println!("cargo::rerun-if-changed=csrc/shim.cpp");
    println!("cargo::rerun-if-changed=csrc/libc_gap.c");
    println!("cargo::rerun-if-changed=build.rs");
}

// ---------------------------------------------------------------------------
// Shared bindgen configuration.
// ---------------------------------------------------------------------------

/// Apply the allowlist/opaque configuration shared by both targets. The header + include args
/// (which differ per target) are configured by the caller.
fn allowlist(builder: bindgen::Builder) -> bindgen::Builder {
    builder
        .allowlist_function("World_.*")
        .allowlist_function("scprintf")
        .allowlist_function("SetPrintFunc")
        .allowlist_type("WorldOptions")
        .allowlist_type("ReplyFunc")
        .allowlist_type("PrintFunc")
        // Allowlist the opaque engine types directly (not just via the functions that reference
        // them). On native they are pulled in by the `World_*` prototypes anyway; on wasm32, libclang
        // does not surface the `extern "C"` function declarations to bindgen (a known cross-target
        // libclang quirk - the `WorldOptions` struct still comes out, with the correct 32-bit
        // layout), so `crates/scsynth-sys` declares the few `World_*` it needs by hand. Allowlisting
        // the type here makes the opaque `World` available on both targets regardless.
        .allowlist_type("World")
        .allowlist_type("HiddenWorld")
        // Keep the engine internals opaque - we only ever hold pointers to these.
        .opaque_type("World")
        .opaque_type("HiddenWorld")
        .opaque_type("ReplyAddress")
        .opaque_type("SndBuf")
        .blocklist_type("std::.*")
        .blocklist_type("boost::.*")
}

/// Generate and write `bindings.rs` to `OUT_DIR`.
fn write_bindings(builder: bindgen::Builder) {
    let bindings = builder.generate().expect("failed to generate bindings");
    let out = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out.join("bindings.rs"))
        .expect("failed to write bindings");
}

// ---------------------------------------------------------------------------
// Pinned-source resolution: env-var override, else git-fetch the pinned revision.
//
// We fetch the upstream source at build time (rather than vendoring it in the crate) because
// SuperCollider is far larger than the crates.io package-size limit; this keeps the published crate
// tiny. Nix and the dev shell set the env var to a pre-fetched checkout, so they never fetch. The
// revision lives in `source.toml`, which the flake reads too - so the two cannot drift.
// ---------------------------------------------------------------------------

/// Resolve a pinned upstream source tree: the `env_var` override if set (Nix / dev shell), else the
/// revision in `source.toml` git-fetched into a rev-keyed cache.
fn resolve_pinned_source(env_var: &str, name: &str, recurse_submodules: bool) -> PathBuf {
    println!("cargo::rerun-if-env-changed={env_var}");
    if let Some(dir) = env::var_os(env_var) {
        return PathBuf::from(dir);
    }
    let (url, rev) = read_pin();
    let dir = source_cache_dir(name, &rev);
    if !dir.join(".fetched").is_file() {
        fetch_pinned_source(&url, &rev, &dir, recurse_submodules);
        fs::write(dir.join(".fetched"), &rev).unwrap();
    }
    dir
}

/// Read `{ url, rev }` from `source.toml` next to this crate. Hand-parsed (two `key = "value"` lines)
/// to avoid a build dependency; it ships inside the published crate so the fetch is self-contained.
fn read_pin() -> (String, String) {
    let path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("source.toml");
    println!("cargo::rerun-if-changed={}", path.display());
    let text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    let field = |key: &str| -> String {
        for line in text.lines() {
            let line = line.trim();
            if line.starts_with('#') {
                continue;
            }
            if let Some((k, v)) = line.split_once('=')
                && k.trim() == key
            {
                return v.trim().trim_matches('"').to_string();
            }
        }
        panic!("`{key}` not found in {}", path.display());
    };
    (field("url"), field("rev"))
}

/// A persistent, rev-keyed cache dir for fetched sources, so `cargo clean` doesn't force a re-fetch.
/// Overridable with `SCSYNTH_SRC_CACHE_DIR`; falls back to `OUT_DIR` when no cache home is available.
fn source_cache_dir(name: &str, rev: &str) -> PathBuf {
    let base = env::var_os("SCSYNTH_SRC_CACHE_DIR")
        .map(PathBuf::from)
        .or_else(|| env::var_os("XDG_CACHE_HOME").map(|c| PathBuf::from(c).join("scsynth-rs")))
        .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/scsynth-rs")))
        .unwrap_or_else(|| PathBuf::from(env::var("OUT_DIR").unwrap()).join("scsynth-rs-src"));
    base.join(name).join(rev)
}

/// Git-fetch `rev` from `url` into `dir`. Prefers a shallow fetch of the exact rev (works on GitHub);
/// falls back to a full fetch for hosts that disallow fetching an arbitrary SHA.
fn fetch_pinned_source(url: &str, rev: &str, dir: &Path, recurse_submodules: bool) {
    let _ = fs::remove_dir_all(dir);
    fs::create_dir_all(dir).unwrap();
    git(dir, &["init", "-q"]);
    git(dir, &["remote", "add", "origin", url]);
    let target = if try_git(dir, &["fetch", "-q", "--depth", "1", "origin", rev]) {
        "FETCH_HEAD".to_string()
    } else {
        git(dir, &["fetch", "-q", "origin"]);
        rev.to_string()
    };
    git(dir, &["checkout", "-q", "--detach", &target]);
    if recurse_submodules {
        git(dir, &["submodule", "update", "--init", "--recursive"]);
    }
}

/// Run `git -C dir <args>`, panicking on failure.
fn git(dir: &Path, args: &[&str]) {
    assert!(
        try_git(dir, args),
        "git {args:?} failed in {}",
        dir.display()
    );
}

/// Run `git -C dir <args>`, returning whether it succeeded.
fn try_git(dir: &Path, args: &[&str]) -> bool {
    std::process::Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

// ---------------------------------------------------------------------------
// Shared helpers.
// ---------------------------------------------------------------------------

/// Emit a native link-search directory.
fn link_search(dir: &Path) {
    println!("cargo:rustc-link-search=native={}", dir.display());
}

/// Recursively copy `src` into `dst`, skipping `.git` entries.
fn copy_tree(src: &Path, dst: &Path) {
    fs::create_dir_all(dst).unwrap();
    for entry in fs::read_dir(src).unwrap() {
        let entry = entry.unwrap();
        if entry.file_name() == ".git" {
            continue;
        }
        let from = entry.path();
        let to = dst.join(entry.file_name());
        let file_type = entry.file_type().unwrap();
        if file_type.is_dir() {
            copy_tree(&from, &to);
        } else if file_type.is_symlink() {
            let target = fs::read_link(&from).unwrap();
            let _ = std::os::unix::fs::symlink(target, &to);
        } else {
            fs::copy(&from, &to).unwrap();
            // `fs::copy` preserves source permissions; a source from the read-only Nix store (or a
            // read-only env-var checkout) would make the copy read-only too, but the build patches
            // some of these files in place - so add the owner-write bit.
            use std::os::unix::fs::PermissionsExt;
            let perms = fs::metadata(&to).unwrap().permissions();
            let mode = perms.mode();
            if mode & 0o200 == 0 {
                fs::set_permissions(&to, fs::Permissions::from_mode(mode | 0o200)).unwrap();
            }
        }
    }
}

/// Write `src` to `dst`, but only if the content differs (preserves mtime to avoid rebuilds).
fn sync_file(src: &Path, dst: &Path) {
    let new = fs::read(src).unwrap();
    if fs::read(dst).is_ok_and(|old| old == new) {
        return;
    }
    fs::create_dir_all(dst.parent().unwrap()).unwrap();
    fs::write(dst, new).unwrap();
}

/// Recursively find the first file named `name` under `root`.
fn find_file(root: &Path, name: &str) -> Option<PathBuf> {
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let entries = fs::read_dir(&dir).ok()?;
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if path.file_name().is_some_and(|n| n == name) {
                return Some(path);
            }
        }
    }
    None
}

// ---------------------------------------------------------------------------
// Native source patch: add an `AUDIOAPI=external` branch compiling our host-pumped driver.
// ---------------------------------------------------------------------------

/// Patch `server/scsynth/CMakeLists.txt` to add an `AUDIOAPI=external` branch that compiles our
/// host-pumped `SC_ExternalDriver.cpp`. Asserts on each edit so an upstream layout change fails
/// loudly rather than silently producing the wrong build.
fn patch_audioapi_external(sc: &Path) {
    // Both scsynth and (the still-configured) sclang validate AUDIOAPI; allow `external` in each.
    for rel in ["server/scsynth/CMakeLists.txt", "lang/CMakeLists.txt"] {
        let path = sc.join(rel);
        let regex_old = "^(jack|coreaudio|portaudio|bela|webaudio)$";
        let regex_new = "^(jack|coreaudio|portaudio|bela|webaudio|external)$";
        let mut text = fs::read_to_string(&path).unwrap();
        assert!(
            text.contains(regex_old),
            "AUDIOAPI validation regex not found in {rel} - SuperCollider CMake layout changed"
        );
        text = text.replace(regex_old, regex_new);
        fs::write(&path, text).unwrap();
    }

    // Add the `external` driver branch to scsynth's audio-API selection.
    let path = sc.join("server/scsynth/CMakeLists.txt");
    let mut text = fs::read_to_string(&path).unwrap();
    let anchor = "add_definitions(\"-DSC_AUDIO_API=SC_AUDIO_API_JACK\")";
    let injected = concat!(
        "add_definitions(\"-DSC_AUDIO_API=SC_AUDIO_API_JACK\")\n",
        "elseif(AUDIOAPI STREQUAL external)\n",
        "\tlist(APPEND scsynth_sources ${CMAKE_SOURCE_DIR}/server/scsynth/SC_ExternalDriver.cpp)\n",
        "\tadd_definitions(\"-DSC_AUDIO_API_EXTERNAL=8\" \"-DSC_AUDIO_API=SC_AUDIO_API_EXTERNAL\")",
    );
    assert!(
        text.contains(anchor),
        "jack AUDIOAPI branch not found - SuperCollider CMake layout changed"
    );
    text = text.replace(anchor, injected);

    fs::write(&path, text).unwrap();
}

// ---------------------------------------------------------------------------
// Wasm source patches: minimal `#ifndef SC_WASM` guards so the engine links without the excluded
// filesystem / shared-memory / device-driver / networking sources. Each edit is `assert!`-guarded
// so an upstream layout change fails loudly rather than silently mis-building.
// ---------------------------------------------------------------------------

fn patch_sc_sources(sc: &Path) {
    // SC_Endian.h: there is no platform branch for bare wasm (it would `#error cannot find
    // endianess`). wasm is little-endian and has no <netinet/in.h>; add an SC_WASM branch that
    // defines the endian macros and SC_NO_ENDIAN_FUNCTIONS (so byte-swaps use the pure-C path),
    // mirroring the existing _WIN32 branch.
    patch(
        sc,
        "include/common/SC_Endian.h",
        &[(
            "#elif defined(__EMSCRIPTEN__)\n\n#    include <endian.h>\n#    include <netinet/in.h>\n",
            "#elif defined(__EMSCRIPTEN__)\n\n#    include <endian.h>\n#    include <netinet/in.h>\n\n\
             #elif defined(SC_WASM) // SC_WASM: wasm is little-endian; no <netinet/in.h>.\n\n\
             #    define LITTLE_ENDIAN 1234\n#    define BIG_ENDIAN 4321\n\
             #    define BYTE_ORDER LITTLE_ENDIAN\n#    define SC_NO_ENDIAN_FUNCTIONS\n",
        )],
    );

    // SC_ReplyImpl.hpp: the ReplyAddress struct embeds a boost::asio ip::address (and includes the
    // whole of <boost/asio.hpp>) unless __EMSCRIPTEN__ is defined. boost::asio pulls networking /
    // threads / OS headers that do not build for bare wasm. Extend the existing emscripten guards to
    // also fire under SC_WASM, exactly as the emscripten web build relies on them.
    patch(
        sc,
        "common/SC_ReplyImpl.hpp",
        &[
            (
                "#ifndef __EMSCRIPTEN__\n#    include <boost/asio.hpp>\n#endif",
                "#if !defined(__EMSCRIPTEN__) && !defined(SC_WASM)\n#    include <boost/asio.hpp>\n#endif",
            ),
            (
                "#ifndef __EMSCRIPTEN__\n    boost::asio::ip::address mAddress;\n#endif",
                "#if !defined(__EMSCRIPTEN__) && !defined(SC_WASM)\n    boost::asio::ip::address mAddress;\n#endif",
            ),
        ],
    );

    // SC_World.cpp: <filesystem> is only needed for World_LoadGraphDefs (mLoadGraphDefs=false, never
    // called). Guard it so we don't link libc++ <filesystem>. (server_shm.hpp / boost::interprocess
    // is pulled in unconditionally via SC_HiddenWorld.h and DOES compile for wasm; the unused
    // shared-memory creator is never instantiated because mSharedMemoryID is forced 0, so its
    // mmap/shm/TLSF symbols only need to LINK - see the syscall stubs in libc_gap.c and TLSF below.)
    patch(
        sc,
        "server/scsynth/SC_World.cpp",
        &[(
            "#include <filesystem>\n\nnamespace fs = std::filesystem;",
            "#ifndef SC_WASM // SC_WASM: <filesystem>/World_LoadGraphDefs unused \
             (mLoadGraphDefs=false).\n#include <filesystem>\n\nnamespace fs = std::filesystem;\n#endif",
        )],
    );
    // SC_World.cpp: guard the body of World_LoadGraphDefs (uses SC_Filesystem + fs::, both excluded).
    // It is only reached when mLoadGraphDefs is true, which we never set.
    patch(
        sc,
        "server/scsynth/SC_World.cpp",
        &[(
            "void World_LoadGraphDefs(World* world) {\n    GraphDef* list = nullptr;",
            "void World_LoadGraphDefs(World* world) {\n#ifndef SC_WASM // SC_WASM: never called \
             (mLoadGraphDefs=false); body uses excluded SC_Filesystem.\n    GraphDef* list = nullptr;",
        )],
    );
    patch(
        sc,
        "server/scsynth/SC_World.cpp",
        &[(
            "        list = GraphDef_LoadDir(world, path, list);\n        GraphDef_Define(world, list);\n    }\n}",
            "        list = GraphDef_LoadDir(world, path, list);\n        GraphDef_Define(world, list);\n    }\n#endif\n}",
        )],
    );

    // SC_GraphDef.cpp: the file-loading GraphDef_Load{,Dir,Glob} + load_file use SC_Filesystem and
    // libc++ <filesystem> directory iteration (the SC_Filesystem.cpp TU is excluded). They are only
    // reached by /d_load and /d_loadDir, which we never send. Replace their bodies with no-ops that
    // return the list unchanged so the symbols still link for the (unsent) command handlers.
    // GraphDef_Recv (the /d_recv path) reads from a memory buffer and is left intact.
    patch(
        sc,
        "server/scsynth/SC_GraphDef.cpp",
        &[(
            "#include \"SC_Filesystem.hpp\"",
            "#ifndef SC_WASM // SC_WASM: SC_Filesystem.cpp TU excluded; file loaders stubbed below.\n\
             #include \"SC_Filesystem.hpp\"\n#endif",
        )],
    );
    // Guard the include of <filesystem>/<fstream> too (the no-op loaders below don't need them,
    // and the signatures only reference std::filesystem::path which <filesystem> in the header
    // SC_GraphDef.h still provides for declarations).
    patch(
        sc,
        "server/scsynth/SC_GraphDef.cpp",
        &[(
            "#include <filesystem>\n#include <fstream>",
            "#include <filesystem>\n#ifndef SC_WASM\n#include <fstream>\n#endif",
        )],
    );
    // Replace the three filesystem loaders + load_file with SC_WASM no-ops (return list unchanged).
    let loaders_old =
        "GraphDef* GraphDef_LoadGlob(World* inWorld, const char* pattern, GraphDef* inList) {";
    let loaders_guard_open =
        "#ifndef SC_WASM // SC_WASM: filesystem loaders excluded (no /d_load on wasm).\n";
    patch(
        sc,
        "server/scsynth/SC_GraphDef.cpp",
        &[(loaders_old, &format!("{loaders_guard_open}{loaders_old}"))],
    );
    // Close the guard after GraphDef_LoadDir and supply no-op definitions for the three symbols.
    let after_loaders = "void UnitSpec_Free(UnitSpec* inUnitSpec);";
    let noops = concat!(
        "#else\n",
        "// SC_WASM no-op loaders: /d_load and /d_loadDir are never sent on wasm, but the command\n",
        "// handlers reference these symbols, so provide list-passthrough definitions.\n",
        "GraphDef* GraphDef_LoadGlob(World*, const char*, GraphDef* inList) { return inList; }\n",
        "GraphDef* GraphDef_Load(World*, const std::filesystem::path&, GraphDef* inList) { return inList; }\n",
        "GraphDef* GraphDef_LoadDir(World*, const std::filesystem::path&, GraphDef* inList) { return inList; }\n",
        "#endif\n",
        "void UnitSpec_Free(UnitSpec* inUnitSpec);",
    );
    patch(
        sc,
        "server/scsynth/SC_GraphDef.cpp",
        &[(after_loaders, noops)],
    );
}

/// Generate `common/SC_Version.hpp` from `SC_Version.hpp.in` (normally done by CMake configure).
/// The version triple comes from the submodule's SCVersion.txt; git ref fields are not used by the
/// engine's runtime, only by /version replies, so fixed placeholders are fine.
fn generate_version_header(sc: &Path) {
    let scver = fs::read_to_string(sc.join("SCVersion.txt")).expect("read SCVersion.txt");
    let field = |name: &str| -> String {
        let needle = format!("set({name} ");
        let line = scver
            .lines()
            .find(|l| l.trim_start().starts_with(&needle))
            .unwrap_or_else(|| panic!("{name} not found in SCVersion.txt"));
        // `set(NAME value)` or `set(NAME "value")`
        let val = line.trim().trim_start_matches(&needle);
        val.trim_end_matches(')')
            .trim()
            .trim_matches('"')
            .to_string()
    };
    let template = sc.join("common/SC_Version.hpp.in");
    let mut text = fs::read_to_string(&template).expect("read SC_Version.hpp.in");
    for (ph, val) in [
        ("@SC_VERSION_MAJOR@", field("SC_VERSION_MAJOR")),
        ("@SC_VERSION_MINOR@", field("SC_VERSION_MINOR")),
        ("@SC_VERSION_PATCH@", field("SC_VERSION_PATCH")),
        ("@SC_VERSION_TWEAK@", field("SC_VERSION_TWEAK")),
        ("@GIT_REF_TYPE@", "na".to_string()),
        ("@GIT_BRANCH_OR_TAG@", "na".to_string()),
        ("@GIT_COMMIT_HASH@", "na".to_string()),
    ] {
        assert!(
            text.contains(ph),
            "version placeholder {ph} missing - template changed"
        );
        text = text.replace(ph, &val);
    }
    fs::write(sc.join("common/SC_Version.hpp"), &text).unwrap();
    // SC_MiscCmds.cpp includes "SC_Version.hpp"; the common/ dir is on the include path.
}

/// Apply a list of `(old, new)` text replacements to `sc/rel`, asserting each `old` is present
/// (so an upstream layout change is caught immediately).
fn patch(sc: &Path, rel: &str, edits: &[(&str, &str)]) {
    let path = sc.join(rel);
    let mut text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {rel}: {e}"));
    for (old, new) in edits {
        assert!(
            text.contains(old),
            "SC_WASM guard anchor not found in {rel} (upstream layout changed):\n{old}",
        );
        text = text.replace(old, new);
    }
    fs::write(&path, text).unwrap();
}
