import java.util.Properties

plugins {
    id("com.android.application")
    id("kotlin-android")
    // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
    id("dev.flutter.flutter-gradle-plugin")
}

// SDD-073 / SDD-109 trace: load release-signing material from a
// developer-local gradle.properties file (preferred) or from environment
// variables (CI). Key custody is governed by SDD-073 item 5 and the
// CI signing assertion hook in SDD-109 item 4. NO key material is ever
// committed to the repository; the four properties below are looked up
// at configure time and may be absent on developer machines.
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties().apply {
    if (keystorePropertiesFile.exists()) {
        keystorePropertiesFile.inputStream().use { load(it) }
    }
}

fun resolveSigningProp(name: String): String? {
    // Precedence: gradle property -> key.properties file -> environment variable.
    val fromProject = if (project.hasProperty(name)) project.property(name)?.toString() else null
    val fromFile = keystoreProperties.getProperty(name)
    val fromEnv = System.getenv(name)
    return fromProject ?: fromFile ?: fromEnv
}

val releaseStoreFile = resolveSigningProp("CHANORA_RELEASE_STORE_FILE")
val releaseStorePassword = resolveSigningProp("CHANORA_RELEASE_STORE_PASSWORD")
val releaseKeyAlias = resolveSigningProp("CHANORA_RELEASE_KEY_ALIAS")
val releaseKeyPassword = resolveSigningProp("CHANORA_RELEASE_KEY_PASSWORD")
val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword).all { !it.isNullOrBlank() }
val splitPerAbiRequested = project.hasProperty("split-per-abi") ||
    gradle.startParameter.taskNames.any { taskName ->
        taskName.contains("split-per-abi", ignoreCase = true)
    }
val configuredAbiSet = listOf("arm64-v8a", "x86_64")
val flutterTargetPlatformArg = gradle.startParameter.projectProperties["target-platform"]
    ?.split(',')
    ?.map { it.trim() }
    ?.firstOrNull()
val singleAbiFromFlutterTargetPlatform = when (flutterTargetPlatformArg) {
    "android-arm64" -> "arm64-v8a"
    "android-x64" -> "x86_64"
    else -> null
}
val effectivePackagingAbis: List<String> = when {
    singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform)
    splitPerAbiRequested -> configuredAbiSet
    else -> configuredAbiSet
}

android {
    namespace = "app.chanora.chanora_flutter"
    compileSdk = flutter.compileSdkVersion
    ndkVersion = flutter.ndkVersion

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    kotlin {
        compilerOptions {
            jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
        }
    }

    defaultConfig {
        applicationId = "app.chanora.chanora_flutter"
        // DEC-004 (register v0.9.5) / SDD-073 item 1: Android minimum API
        // 28 (Android 9.0). Raised from the original recommendation of
        // API 24 by owner ruling on 2026-05-14 for a simpler audio path
        // (AAudio stable from API 28) and a narrower compatibility /
        // scoped-storage surface. Do NOT lower without re-opening DEC-004.
        minSdk = 28
        // DEC-005 / SDD-073 item 2 / SRS-188: target the Google
        // Play-required API level on upload date. Flutter's default is
        // kept; release-time CI must verify this still satisfies the
        // current Play policy (SDD-109 release-inspection hook).
        targetSdk = flutter.targetSdkVersion
        versionCode = flutter.versionCode
        versionName = flutter.versionName

        // SDD-073 item 4: NDK ABIs pinned to arm64-v8a, armeabi-v7a,
        // and x86_64. Other ABIs (x86, mips, …) shall not be packaged.
        // Per-ABI delivery is handled by the AAB bundle splits below
        // (SDD-109 item 2) rather than a fat APK.
        // When Flutter --split-per-abi is active, skip ndk.abiFilters to avoid
        // conflicting with the Gradle splits.abi mechanism that Flutter injects.
        // The per-ABI filtering is still handled by the jniLibs excludes below
        // and the Rust cargo-ndk per-ABI build tasks.
        if (!splitPerAbiRequested) {
            ndk {
                // DEC-032 RESOLVED (2026-05-18): canonical two-ABI set
                // (arm64-v8a + x86_64) restored after the audiopus_sys
                // ANDROID_ABI propagation gap was closed by the workspace
                // [patch.crates-io] override pinning `cmake` to the fork
                // carrying cmake-rs PR #257 (forwards ANDROID_ABI /
                // ANDROID_PLATFORM as -D variables to the child cmake
                // invocation). See Cargo.toml [patch.crates-io] block and
                // docs/governance/product-decision-register.md DEC-032.
                abiFilters += effectivePackagingAbis
            }
        }
    }

    // SDD-073 item 5 / SDD-109 item 4: release signing is sourced from
    // CI-provided credentials. If any of the four properties is absent,
    // the signingConfig is intentionally NOT registered so the release
    // build fails fast at task-execution time with a clear message,
    // rather than silently falling back to the debug keystore.
    signingConfigs {
        if (hasReleaseSigning) {
            create("release") {
                storeFile = file(releaseStoreFile!!)
                storePassword = releaseStorePassword
                keyAlias = releaseKeyAlias
                keyPassword = releaseKeyPassword
            }
        }
    }

    buildTypes {
        release {
            // SDD-073 item 5: release builds MUST use a non-debug
            // signing config. When the four CHANORA_RELEASE_* inputs
            // are present (CI), wire the dedicated release signingConfig.
            // When they are absent (developer machine without keys),
            // leave signingConfig unset so any attempt to assemble a
            // release artefact fails with the canonical Gradle error
            // "No signing config provided for variant release" — this
            // matches the SDD-109 CI signing assertion stance and
            // prevents accidental debug-key-signed release outputs.
            if (hasReleaseSigning) {
                signingConfig = signingConfigs.getByName("release")
            } else {
                logger.warn(
                    "[SDD-073] Release signing credentials are not configured " +
                        "(CHANORA_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / " +
                        "_KEY_PASSWORD). Release tasks will fail; debug fallback " +
                        "is intentionally disabled per SDD-073 item 5."
                )
            }

            // SDD-073 item 6: enable R8 + resource shrinking for release.
            // Keep rules live in proguard-rules.pro; see that file for
            // the JNI / FRB / native-method retention stance.
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }

        debug {
            // SDD-073 item 6: debug builds run unminified for developer
            // ergonomics and stack-trace readability.
            isMinifyEnabled = false
        }
    }

    packaging {
        jniLibs {
            if (singleAbiFromFlutterTargetPlatform != null) {
                val excludedAbis = configuredAbiSet.filter { it != singleAbiFromFlutterTargetPlatform } +
                    listOf("armeabi-v7a", "x86")
                excludes += excludedAbis.map { abi -> "lib/$abi/**" }
            }
        }
    }

    // SDD-109 item 2: Android App Bundle (.aab) split configuration.
    // Combined with the SDD-073 abiFilters set, this yields two
    // native splits (arm64-v8a, x86_64), per-language resource
    // delivery, and per-density resource delivery. Each device
    // receives only its matching ABI .so — no cross-ABI bundling.
    bundle {
        language {
            enableSplit = true
        }
        density {
            enableSplit = true
        }
        abi {
            enableSplit = true
        }
    }
}

// ONNX Runtime native library for Silero VAD.
// The ort crate (Rust) loads libonnxruntime.so via dlopen at runtime
// (`load-dynamic` feature).  The AAR ships the .so for arm64-v8a,
// armeabi-v7a, x86_64, x86.  AGP merges these into the APK/AAB.
dependencies {
    implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
}

flutter {
    source = "../.."
}

// ---------------------------------------------------------------------------
// SDD-118: AndroidBridgeBuildAutomation
//
// Auto-build the `chanora_bridge` Rust cdylib via `cargo-ndk` and stage the
// resulting per-ABI `.so` files into `src/main/jniLibs/<abi>/` so AGP picks
// them up during the normal JNI-lib merge step. No manual `cargo ndk`
// invocations and no parent-shell environment mutation are required.
//
// Cross-trace: SDD-073 (Android build config — minSdk, ABI filters),
// SDD-105 (JNI bootstrap — consumer of the produced .so),
// SDD-109 (AAB pipeline — downstream packaging consumer of staged jniLibs).
//
// SDD-118 item 12 (caching across clean builds): cargo's `target/` directory
// lives at the repository root (outside `apps/chanora_flutter/android/`) and
// is intentionally OUTSIDE Gradle's `clean` scope. Do NOT register `target/`
// (or any subpath) as a Gradle output; doing so would make `./gradlew clean`
// destroy cargo's incremental cache.
//
// SDD-118 item 11 (idempotency): Gradle inputs/outputs gate whether the task
// runs at all; cargo's own incremental cache decides whether the run actually
// relinks. No manual timestamp guards.
// ---------------------------------------------------------------------------

// SDD-118 item 2: cargo workspace root (repository root). From the :app
// project (apps/chanora_flutter/android/app) we ascend three levels to reach
// the cargo workspace at the repository root where Cargo.toml / Cargo.lock /
// crates/ live. rootProject.projectDir is the Flutter Android root project
// (apps/chanora_flutter/android), so three levels up reaches the repo root.
val cargoWorkspaceRoot: File = rootProject.projectDir.resolve("../../..").canonicalFile

// SDD-118 item 6 (extended): NDK prebuilt host tag. The NDK ships
// host-specific prebuilt toolchains under
// `$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/<host-tag>/…`; the tag is
// the only path component that varies across developer/CI hosts. Detect
// at configure time so the same Gradle file builds on Linux, macOS, and
// Windows CI runners without manual editing.
val ndkHostTag: String = when {
    org.gradle.internal.os.OperatingSystem.current().isMacOsX -> "darwin-x86_64"
    org.gradle.internal.os.OperatingSystem.current().isWindows -> "windows-x86_64"
    else -> "linux-x86_64"
}

// SDD-118 item 2: minimum API level source of truth — the
// `chanora.android.minSdk` Gradle property mandated by SDD-073 item 1,
// defaulting to 28 per DEC-004. Overridable via `-Pchanora.android.minSdk=…`
// or via `gradle.properties`. Do NOT hard-code a numeric API level elsewhere.
val chanoraAndroidMinSdk: Provider<String> =
    providers.gradleProperty("chanora.android.minSdk").orElse("28")

// SDD-118 item 6: explicit Rust-triple ↔ Android-ABI mapping. cargo-ndk
// performs the triple mapping internally; we only pass the Android ABI name
// to its `-t` flag, but we need the Rust triple to locate `target/<triple>/…`
// for the per-ABI copy and for the inputs/outputs declaration.
val abiToRustTriple: Map<String, String> = mapOf(
    "arm64-v8a" to "aarch64-linux-android",
    "x86_64" to "x86_64-linux-android",
)

// SDD-118 item 3: ABI set derived from the existing
// `android.defaultConfig.ndk.abiFilters` declaration owned by SDD-073 item 4
// so that the build-automation list and the AGP packaging list are guaranteed
// in sync. Any unsupported ABI fails fast.
val configuredAbis: List<String> = when {
    singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform)
    splitPerAbiRequested -> configuredAbiSet
    else -> android.defaultConfig.ndk.abiFilters.toList()
}
configuredAbis.forEach { abi ->
    require(abiToRustTriple.containsKey(abi)) {
        "[SDD-118] ABI '$abi' is not supported by SDD-118; see SDD-073 item 4. " +
            "Supported ABIs: ${abiToRustTriple.keys.joinToString(", ")}."
    }
}

// SDD-118 item 9: preflight check task. Fails loudly with actionable
// remediation commands if cargo / cargo-ndk / required rustup targets are
// missing. Does NOT auto-install and does NOT mutate user state.
val checkRustBridgeToolchain = tasks.register("checkRustBridgeToolchain") {
    group = "chanora_bridge"
    description = "SDD-118 item 9: verify cargo, cargo-ndk, and Android rustup targets are installed."
    doLast {
        // cargo presence
        val cargoResult = providers.exec {
            commandLine("cargo", "--version")
            isIgnoreExitValue = true
        }.result.get()
        if (cargoResult.exitValue != 0) {
            throw GradleException(
                "[SDD-118] `cargo` is not on PATH. Install Rust via https://rustup.rs " +
                    "and ensure ~/.cargo/bin is on PATH."
            )
        }

        // cargo-ndk presence
        val cargoNdkResult = providers.exec {
            commandLine("cargo", "ndk", "--version")
            isIgnoreExitValue = true
        }.result.get()
        if (cargoNdkResult.exitValue != 0) {
            throw GradleException(
                "[SDD-118] `cargo-ndk` is required. Install: cargo install cargo-ndk"
            )
        }

        // rustup targets
        val rustupOutput = providers.exec {
            commandLine("rustup", "target", "list", "--installed")
            isIgnoreExitValue = true
        }.standardOutput.asText.get()
        val requiredTriples = abiToRustTriple.values.toSet()
        val installed = rustupOutput.lines().map { it.trim() }.filter { it.isNotEmpty() }.toSet()
        val missing = requiredTriples - installed
        if (missing.isNotEmpty()) {
            throw GradleException(
                "[SDD-118] Missing Android Rust target(s): ${missing.joinToString(", ")}. " +
                    "Install: rustup target add aarch64-linux-android x86_64-linux-android"
            )
        }
    }
}

// SDD-118 item 13 (corrected): one cargo-ndk invocation per ABI rather
// than a single multi-`-t` invocation. The single-shot form is shorter
// but leaks ANDROID_ABI state across iterations, causing audiopus_sys's
// child CMake invocation for x86_64-linux-android to collide with the
// NDK toolchain file's armv7 default. Per-ABI invocation gives
// cargo-ndk a clean ANDROID_ABI per call and isolates failure scope to
// a single ABI.
//
// SDD-118 item 4: profile mapping — Gradle `debug` invokes `cargo build`
// (no `--release`); Gradle `release` invokes `cargo build --release`. We
// register one aggregate task per profile that fans out to one Exec
// sub-task per (profile, ABI) tuple.
fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
    val capitalized = profile.replaceFirstChar { it.uppercase() }

    // PascalCase ABI suffix for sub-task names (e.g. arm64-v8a -> Arm64V8a).
    fun abiToTaskSuffix(abi: String): String =
        abi.split('-', '_').joinToString("") { part ->
            part.replaceFirstChar { it.uppercase() }
        }

    // SDD-118 item 5: shared env-var setup. Resolve NDK / CMake toolchain
    // path once at configuration time; applied identically to every per-ABI
    // sub-task below.
    val effectiveNdkPath = System.getenv("ANDROID_NDK_HOME")
        .takeUnless { it.isNullOrBlank() }
        ?: android.ndkDirectory.absolutePath
    val cmakeToolchainFile = "$effectiveNdkPath/build/cmake/android.toolchain.cmake"
    val homeDir = System.getenv("HOME") ?: System.getProperty("user.home")
    val rustupBinDir = listOf(
        "$homeDir/.cargo/bin",
        "/opt/homebrew/opt/rustup/bin",
        "/usr/local/opt/rustup/bin"
    ).firstOrNull { File(it).resolve("cargo").exists() }
    val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
    val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
    val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"

    // SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
    // isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
    // cleanly for both the Rust compile and any child CMake invocation.
    val perAbiTaskProviders = configuredAbis.map { abi ->
        val triple = abiToRustTriple.getValue(abi)
        val abiSuffix = abiToTaskSuffix(abi)
        tasks.register<Exec>("buildRustBridge$capitalized$abiSuffix") {
            group = "chanora_bridge"
            description = "SDD-118 items 1/4/13 (corrected): build chanora_bridge cdylib (profile=$profile, abi=$abi) via cargo-ndk."
            dependsOn(checkRustBridgeToolchain)

            // SDD-118 item 2: cargo workspace root is the repo root.
            workingDir = cargoWorkspaceRoot

            // SDD-118 item 13 (corrected): single `-t <abi>` per invocation.
            val profileArgs = if (profile == "release") listOf("--release") else emptyList()
            val cmd = mutableListOf("cargo", "ndk", "-t", abi)
            cmd.add("--platform")
            cmd.add(chanoraAndroidMinSdk.get())
            cmd.add("--")
            cmd.add("build")
            cmd.addAll(profileArgs)
            cmd.add("-p")
            cmd.add("chanora_bridge")
            commandLine(cmd)

            // SDD-118 item 5 (corrected v3): completely replace the inherited
            // environment with a deliberately-minimized map. The Gradle daemon
            // accumulates env state from prior per-ABI cargo-ndk invocations
            // (CFLAGS, CC, AR, CMAKE_*, RUSTFLAGS); audiopus_sys's cmake crate
            // reads these before falling through to the NDK toolchain file's
            // per-target settings, causing cross-ABI flag contamination (notably
            // armv7's --target=armv7-none-linux-androideabi21 + -march=armv7-a
            // leaks into the x86_64 task's clang command line). Resetting the
            // env per task isolates each ABI from the rest.
            //
            // Deliberately NOT propagated (cross-ABI contamination vectors):
            //   CFLAGS, CXXFLAGS, CPPFLAGS, LDFLAGS, ASFLAGS
            //   CC, CXX, AR, AS, LD, NM, RANLIB, STRIP, OBJCOPY, OBJDUMP
            //   CMAKE_C_FLAGS, CMAKE_CXX_FLAGS, CMAKE_C_COMPILER,
            //     CMAKE_CXX_COMPILER, CMAKE_AR, CMAKE_LINKER, CMAKE_* (other)
            //   RUSTFLAGS
            //   Any per-target *_aarch64_linux_android,
            //     *_armv7_linux_androideabi, *_x86_64_linux_android,
            //     *_i686_linux_android variants of the above
            //   Any CARGO_TARGET_* env vars set by an outer build wrapper
            val cleanEnv = buildMap<String, String> {
                // Pass-through from the calling environment, only the safe set.
                System.getenv("PATH")?.let { path ->
                    put("PATH", listOfNotNull(rustupBinDir, path).joinToString(":"))
                }
                put("HOME", homeDir)
                System.getenv("USER")?.let { put("USER", it) }
                System.getenv("TMPDIR")?.let { put("TMPDIR", it) }
                System.getenv("TEMP")?.let { put("TEMP", it) }
                System.getenv("LANG")?.let { put("LANG", it) }
                System.getenv("LC_ALL")?.let { put("LC_ALL", it) }
                put("CARGO_HOME", cargoHome)
                put("RUSTUP_HOME", rustupHome)
                put("RUSTUP_TOOLCHAIN", rustupToolchain)
                System.getenv("JAVA_HOME")?.let { put("JAVA_HOME", it) }
                // Per-task explicit settings (override any pass-through).
                // SDD-118 item 5: audiopus_sys needs libopus built from source
                // statically because the NDK sysroot ships none.
                put("LIBOPUS_STATIC", "1")
                put("LIBOPUS_NO_PKG", "1")
                put("CMAKE_POLICY_VERSION_MINIMUM", "3.5")
                put("ANDROID_NDK_HOME", effectiveNdkPath)
                put("ANDROID_NDK_ROOT", effectiveNdkPath)
                // SDD-118 item 5 (extended): pin the CMake toolchain file so
                // child CMake invocations spawned by build scripts (notably
                // audiopus_sys via the `cmake` crate) can locate the Android NDK
                // toolchain file at $NDK_HOME/build/cmake/android.toolchain.cmake.
                put("CMAKE_TOOLCHAIN_FILE", cmakeToolchainFile)
                // SDD-118 item 13 (corrected) — explicitly pin ANDROID_ABI and
                // ANDROID_PLATFORM for this invocation. cargo-ndk already sets
                // ANDROID_ABI per `-t` flag, but setting it ourselves forecloses
                // any chance of a stale value leaking into audiopus_sys's child
                // CMake process (the original single-shot bug).
                put("ANDROID_ABI", abi)
                put("ANDROID_PLATFORM", "android-${chanoraAndroidMinSdk.get()}")
            }
            // REPLACES the inherited environment (does not augment it).
            environment = cleanEnv

            // SDD-118 item 8: Gradle up-to-date semantics. Rust source tree,
            // workspace manifest, and lockfile are inputs. The single per-ABI
            // target/.so is the output of this Exec; the jniLibs copy is a
            // separate task with its own outputs (item 7 below).
            inputs.dir(cargoWorkspaceRoot.resolve("crates"))
            inputs.file(cargoWorkspaceRoot.resolve("Cargo.toml"))
            inputs.file(cargoWorkspaceRoot.resolve("Cargo.lock"))
            inputs.property("profile", profile)
            inputs.property("minSdk", chanoraAndroidMinSdk)
            inputs.property("abi", abi)
            outputs.file(cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so"))
        }
    }

    // SDD-118 item 13 (corrected): aggregate no-op task. Downstream tasks
    // (the copy task, the merge<Variant>JniLibFolders hook) continue to
    // depend on `buildRustBridge${Profile}` as before; Gradle's dependsOn
    // graph transitively pulls in every per-ABI sub-task. Per-ABI tasks are
    // independent and can fail in isolation; Gradle may also choose to run
    // them in parallel via its worker pool.
    return tasks.register("buildRustBridge$capitalized") {
        group = "chanora_bridge"
        description = "SDD-118 items 1/4/13 (corrected): aggregate chanora_bridge build (profile=$profile) — fans out to per-ABI sub-tasks."
        dependsOn(perAbiTaskProviders)
    }
}

val buildRustBridgeDebug = registerCargoNdkBuildTask("debug")
val buildRustBridgeRelease = registerCargoNdkBuildTask("release")

// SDD-118 item 6: per-ABI plain copy (no symlinks, no fat binary) from
// target/<triple>/<profile>/libchanora_bridge.so into
// src/main/jniLibs/<abi>/libchanora_bridge.so. Overwrites prior staging.
//
// SDD-118 item 6 (extended): also stage libc++_shared.so from the
// NDK sysroot. libchanora_bridge.so is dynamically linked against
// the NDK's shared C++ runtime (via audiopus_sys / opus-cpp and
// oboe-sys); without libc++_shared.so co-located in jniLibs/<abi>/
// the Android dynamic loader fails at first library load with
// UnsatisfiedLinkError: cannot locate symbol "__cxa_pure_virtual".
//
// SDD-118 item 6 (extended): Android-ABI ↔ NDK-sysroot-triple map.
// Note this is NOT the same as `abiToRustTriple`: the NDK sysroot
// uses `arm-linux-androideabi` for 32-bit ARM whereas Rust uses
// `armv7-linux-androideabi`. The sysroot directory names are the
// authoritative source; verified by listing
// $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/.
val abiToNdkSysrootTriple: Map<String, String> = mapOf(
    "arm64-v8a" to "aarch64-linux-android",
    "x86_64" to "x86_64-linux-android",
)
fun registerJniLibsCopyTask(profile: String): TaskProvider<Task> {
    val capitalized = profile.replaceFirstChar { it.uppercase() }
    val buildTask = if (profile == "release") buildRustBridgeRelease else buildRustBridgeDebug

    // SDD-118 item 6 (extended): resolve NDK path the same way the
    // cargo-ndk task does (env override, fall back to AGP default).
    val effectiveNdkPath = System.getenv("ANDROID_NDK_HOME")
        .takeUnless { it.isNullOrBlank() }
        ?: android.ndkDirectory.absolutePath

    return tasks.register("copyRustBridgeJniLibs$capitalized") {
        group = "chanora_bridge"
        description = "SDD-118 item 6 (extended): strip and stage per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) into src/main/jniLibs/."
        dependsOn(buildTask)
        val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs").asFile
        val llvmStrip = file(
            "$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/bin/llvm-strip"
        )
        doFirst {
            jniLibsDir
                .listFiles()
                ?.filter { it.isDirectory && !configuredAbis.contains(it.name) }
                ?.forEach { staleDir ->
                    staleDir.deleteRecursively()
                }
        }
        configuredAbis.forEach { abi ->
            val triple = abiToRustTriple.getValue(abi)
            val sysrootTriple = abiToNdkSysrootTriple.getValue(abi)
            val bridgeSo = cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so")
            val strippedBridgeSo = layout.buildDirectory
                .file("intermediates/stripped_rust_jni/$profile/$abi/libchanora_bridge.so")
                .get()
                .asFile
            // SDD-118 item 6 (extended): also stage libc++_shared.so from the
            // NDK sysroot. libchanora_bridge.so is dynamically linked against
            // the NDK's shared C++ runtime (via audiopus_sys / opus-cpp and
            // oboe-sys); without libc++_shared.so co-located in jniLibs/<abi>/
            // the Android dynamic loader fails at first library load with
            // UnsatisfiedLinkError: cannot locate symbol "__cxa_pure_virtual".
            val cxxSharedSo = file(
                "$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/sysroot/usr/lib/$sysrootTriple/libc++_shared.so"
            )
            // SDD-118 item 8: input tracking for both staged sources so
            // Gradle correctly invalidates when either changes (e.g. NDK
            // version bump replacing libc++_shared.so).
            inputs.file(bridgeSo).withPropertyName("bridgeSo_$abi")
            inputs.file(cxxSharedSo).withPropertyName("cxxSharedSo_$abi")
            inputs.file(llvmStrip).withPropertyName("llvmStrip_$abi")
            outputs.file(strippedBridgeSo)
            outputs.file(File(jniLibsDir, "$abi/libchanora_bridge.so"))
            outputs.file(File(jniLibsDir, "$abi/libc++_shared.so"))

            doLast {
                val abiDir = File(jniLibsDir, abi)
                abiDir.mkdirs()
                strippedBridgeSo.parentFile.mkdirs()
                bridgeSo.copyTo(strippedBridgeSo, overwrite = true)
                project.exec {
                    commandLine(
                        llvmStrip.absolutePath,
                        "--strip-debug",
                        strippedBridgeSo.absolutePath,
                    )
                }
                strippedBridgeSo.copyTo(
                    File(abiDir, "libchanora_bridge.so"),
                    overwrite = true,
                )
                cxxSharedSo.copyTo(
                    File(abiDir, "libc++_shared.so"),
                    overwrite = true,
                )
            }
        }
    }
}

val copyRustBridgeJniLibsDebug = registerJniLibsCopyTask("debug")
val copyRustBridgeJniLibsRelease = registerJniLibsCopyTask("release")

// SDD-118 item 10: release-inspection assertion. After the copy step and
// before the AGP merge-JniLibFolders node, verify each expected .so exists
// at its destination and is larger than 1 KiB. Catches silent cargo-ndk
// task skips and empty-link-output regressions before they ship into an AAB.
fun registerAssertStagedTask(profile: String): TaskProvider<Task> {
    val capitalized = profile.replaceFirstChar { it.uppercase() }
    val copyTask = if (profile == "release") copyRustBridgeJniLibsRelease else copyRustBridgeJniLibsDebug
    return tasks.register("assertRustBridgeStaged$capitalized") {
        group = "chanora_bridge"
        description = "SDD-118 item 10 (extended): assert per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) are staged and >1 KiB."
        dependsOn(copyTask)
        doLast {
            configuredAbis.forEach { abi ->
                // SDD-118 item 10 (extended): assert both the bridge .so and
                // the co-staged libc++_shared.so. Missing C++ runtime is the
                // exact failure mode that produced the
                // `cannot locate symbol "__cxa_pure_virtual"` cold-launch crash.
                listOf("libchanora_bridge.so", "libc++_shared.so").forEach { soName ->
                    val staged = layout.projectDirectory
                        .file("src/main/jniLibs/$abi/$soName").asFile
                    if (!staged.exists()) {
                        throw GradleException(
                            "[SDD-118 item 10] Missing staged native library '$soName' for ABI '$abi' " +
                                "(profile=$profile): expected at ${staged.absolutePath}. " +
                                "Did cargo-ndk fail or silently skip, or is the NDK sysroot missing libc++_shared.so?"
                        )
                    }
                    if (staged.length() <= 1024) {
                        throw GradleException(
                            "[SDD-118 item 10] Staged native library '$soName' for ABI '$abi' " +
                                "(profile=$profile) is trivially small (${staged.length()} bytes) " +
                                "at ${staged.absolutePath}. Suspected empty-link-output regression."
                        )
                    }
                }
            }
        }
    }
}

val assertRustBridgeStagedDebug = registerAssertStagedTask("debug")
val assertRustBridgeStagedRelease = registerAssertStagedTask("release")

// SDD-118 item 7: task-graph wiring. Attach the build+copy+assert chain to
// AGP's per-variant `merge<Variant>JniLibFolders` task. This is the correct
// graph node — earlier than preBuild would over-trigger (e.g. IDE sync),
// later would race AGP's jniLibs packaging.
tasks.matching {
    it.name.startsWith("merge") && it.name.endsWith("JniLibFolders")
}.configureEach {
    val lowerName = name.lowercase()
    val isRelease = lowerName.contains("release")
    dependsOn(
        if (isRelease) assertRustBridgeStagedRelease else assertRustBridgeStagedDebug
    )
}
