//! The Android side of the JNI boundary (G-4).
//!
//! Three jobs, all of which exist because Android is not a normal Unix process:
//! initialising btleplug's Java backend, tracking a radio that the rider can
//! switch off underneath us, and getting `tracing` output somewhere a person can
//! read it.
//!
//! Everything above this file is platform-agnostic — `bikecontrol-ble` uses the
//! same `btleplug::platform::Manager` on every target (NFR-5).

use std::ffi::CString;
use std::io;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::OnceLock;

use jni::objects::{GlobalRef, JClass, JObject};
use jni::sys::jboolean;
use jni::{JNIEnv, JavaVM};
use tracing_subscriber::fmt::MakeWriter;

/// `paris.tourolle.bikecontrol.MainActivity.initBtleplug()`.
///
/// btleplug's Android backend ("droidplug") is a hybrid Rust/Java crate: the
/// GATT work happens in Java and Rust drives it over JNI. Before any of it
/// works, `btleplug::platform::init` has to run once with a `JNIEnv` so it can
/// register its native methods on those Java classes and cache their class
/// references.
///
/// That call cannot be made from Rust's own startup. JNI resolves classes with
/// the *calling thread's* class loader, and a thread Rust spawned has only the
/// bootstrap loader — `com.nonpolynomial.btleplug.…` is not on it. The lookup
/// would fail and the first scan would die with ClassNotFound, seconds after
/// launch and a long way from the cause. So the call comes the other way:
/// `MainActivity.onCreate` invokes this native method on the Android main
/// thread, whose class loader is the app's.
///
/// The symbol name is the JNI mangling of that method and is matched by the
/// runtime, not the compiler: if the Kotlin package or method name changes,
/// this silently stops being called. `MainActivity.kt` and this symbol are a
/// pair.
///
/// Failure is logged rather than fatal. A phone with Bluetooth switched off in
/// Settings fails here, and killing the app over that is worse than starting
/// and reporting "no trainer" on the connection screen — which is what the UI
/// already does for every other flavour of adapter trouble.
#[no_mangle]
pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_initBtleplug(
    env: JNIEnv,
    _class: JClass,
) {
    // Stash the VM on the earliest call that has one. `nativeSetActivity` also
    // sets it, but this runs first and `install_async_runtime` needs it before
    // the activity has finished being constructed.
    if let Ok(vm) = env.get_java_vm() {
        let _ = JVM.set(vm);
    }

    // btleplug's futures are Java objects wrapped by `jni-utils`, and every one
    // of its wrappers resolves its class through a cache that only
    // `jni_utils::init` fills. droidplug does not call it — its own `init`
    // registers droidplug's classes and assumes the application has already
    // done this one. Miss it and scanning still works, because scan results
    // arrive on a plain callback, while the first `connect` panics on an
    // unwrapped `None` deep inside jni-utils, killing the task that was
    // connecting. Order matters: this must come first.
    if let Err(e) = jni_utils::init(&env) {
        tracing::error!("jni-utils failed to initialise: {e}");
        return;
    }

    match btleplug::platform::init(&env) {
        Ok(()) => {
            READY.store(true, Ordering::Release);
            tracing::info!("btleplug Android backend initialised");
        }
        Err(e) => tracing::error!("btleplug Android backend failed to initialise: {e}"),
    }
}

/// Give Tauri an async runtime whose threads can talk to Java.
///
/// This is not an optimisation — without it *nothing* BLE works on Android, and
/// it took a device to find out. droidplug reaches the JVM through
/// `JavaVM::get_env()`, which does **not** attach: it returns
/// `JniCall(ThreadDetached)` on any thread the JVM has never seen. Every BLE
/// call in this app is made from a Tauri task, and Tauri's default runtime
/// spawns plain Rust worker threads, so every single scan failed with
/// `bluetooth error: JNI call failed` — an error that says nothing whatsoever
/// about threads and is why this cost an evening.
///
/// Attaching inside the tasks would not fix it. A Tokio task can be moved to a
/// different worker thread at any `.await`, so the thread that starts a scan is
/// not necessarily the one that polls it next; the attachment has to belong to
/// the *threads*, not the work. `on_thread_start` is the only hook that gets
/// that right, and it covers the blocking pool as well as the workers.
///
/// Permanent, not scoped: a scoped attachment detaches at the end of the guard,
/// which for a worker thread means detaching after the first task it runs.
///
/// Must be called before anything spawns — `tauri::async_runtime::set` only
/// affects later work, so a task spawned before this lands on the default
/// runtime and fails exactly as before.
pub fn install_async_runtime() {
    let Some(vm) = JVM.get() else {
        // initBtleplug runs from MainActivity.onCreate before the super chain
        // that starts us, so this is unreachable in practice — but if it ever
        // happens, every BLE call is about to fail and the reason must not be
        // a mystery a second time.
        tracing::error!("no JavaVM yet; BLE will fail with ThreadDetached on every call");
        return;
    };

    // `&'static JavaVM` out of the OnceLock, rather than the raw pointer: the
    // pointer is not `Send`, and `JavaVM` is exactly the wrapper that asserts
    // it is safe to share.
    let runtime = match tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .thread_name("bikecontrol-rt")
        .on_thread_start(move || {
            if let Err(e) = vm.attach_current_thread_permanently() {
                tracing::error!("could not attach runtime thread to the JVM: {e}");
            }
        })
        .build()
    {
        Ok(runtime) => runtime,
        Err(e) => {
            tracing::error!("could not build the JVM-attached runtime: {e}");
            return;
        }
    };

    tauri::async_runtime::set(runtime.handle().clone());
    // The handle Tauri now holds does not keep the runtime alive, and dropping
    // it here would shut down every worker before the first task. It lives as
    // long as the process by design.
    Box::leak(Box::new(runtime));
    tracing::info!("async runtime installed with JVM-attached threads");
}

/// Whether `initBtleplug` has succeeded.
///
/// This has to be checked before *every* call into btleplug, because failing
/// soft here is not the same as failing soft downstream: droidplug's
/// `global_adapter()` is an `expect`, so a call made before a successful init
/// panics rather than returning `Err`. Inside the scan task that panic is
/// invisible — it kills the task and scanning is simply dead for the rest of the
/// process (NFR-4). Asking first turns that into an ordinary "no adapter",
/// which the connection screen already knows how to show.
///
/// Not a latch on our own logic: `MainActivity.onResume` retries the init, so
/// this flips to true if the rider switches Bluetooth on and comes back.
static READY: AtomicBool = AtomicBool::new(false);

pub fn ready() -> bool {
    READY.load(Ordering::Acquire)
}

// ---------------------------------------------------------------------------
// Adapter state.
//
// btleplug models "which adapters exist", not "is the radio on", and on Android
// those are different questions: a disabled adapter is still returned by
// `getDefaultAdapter()` and its scans simply find nothing. Left to itself the
// connection screen would report an empty room to a rider whose Bluetooth is
// off. `MainActivity` watches ACTION_STATE_CHANGED and pushes the answer here.

const UNKNOWN: u8 = 0;
const OFF: u8 = 1;
const ON: u8 = 2;

static BLUETOOTH: AtomicU8 = AtomicU8::new(UNKNOWN);

/// The activity, for the one call that goes Rust -> Java.
///
/// A `GlobalRef` because the local ref handed to `nativeSetActivity` dies when
/// that call returns, and the JVM so any thread can attach to make the call.
static ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
static JVM: OnceLock<JavaVM> = OnceLock::new();

/// `MainActivity.nativeSetActivity(activity)`.
#[no_mangle]
pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_nativeSetActivity(
    env: JNIEnv,
    _this: JObject,
    activity: JObject,
) {
    match (env.get_java_vm(), env.new_global_ref(activity)) {
        (Ok(vm), Ok(global)) => {
            let _ = JVM.set(vm);
            let _ = ACTIVITY.set(global);
        }
        _ => tracing::error!("could not retain the activity; cannot ask to enable Bluetooth"),
    }
}

/// `MainActivity.nativeBluetoothStateChanged(enabled)`.
#[no_mangle]
pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_nativeBluetoothStateChanged(
    _env: JNIEnv,
    _this: JObject,
    enabled: jboolean,
) {
    let state = if enabled != 0 { ON } else { OFF };
    if BLUETOOTH.swap(state, Ordering::Release) != state {
        tracing::info!(enabled = state == ON, "Bluetooth adapter state changed");
    }
}

/// `None` until the first report from Java.
///
/// The distinction matters at startup: the scan loop may run before the activity
/// has said anything, and "unknown" must not be treated as "off" — that would
/// put a spurious "switch Bluetooth on" in front of a rider whose radio is fine.
pub fn bluetooth_enabled() -> Option<bool> {
    match BLUETOOTH.load(Ordering::Acquire) {
        ON => Some(true),
        OFF => Some(false),
        _ => None,
    }
}

/// Ask the rider to switch the radio on, via the system dialog.
///
/// Best-effort by construction: `MainActivity.requestBluetoothEnable` declines
/// quietly if the radio is already on or BLUETOOTH_CONNECT is missing, and this
/// side declines quietly if the activity is gone. Nothing here is on the ride
/// path — the worst case is that no dialog appears and the connection screen
/// goes on saying Bluetooth is off, which is still true and still actionable.
pub fn request_bluetooth_enable() {
    let (Some(vm), Some(activity)) = (JVM.get(), ACTIVITY.get()) else {
        tracing::warn!("no activity retained; cannot ask to enable Bluetooth");
        return;
    };
    // Attaching is required because this runs on a Tauri command thread, not a
    // Java one. Calling a method *on an object* needs no class lookup, so the
    // class-loader problem that forces `initBtleplug` onto the main thread does
    // not apply here.
    let result = vm.attach_current_thread().and_then(|env| {
        env.call_method(activity.as_obj(), "requestBluetoothEnable", "()V", &[])
            .map(|_| ())
    });
    if let Err(e) = result {
        tracing::warn!("failed to ask for Bluetooth: {e}");
    }
}

// ---------------------------------------------------------------------------
// Logging.

const ANDROID_LOG_INFO: i32 = 4;
const TAG: &str = "BikeControl";

#[link(name = "log")]
extern "C" {
    fn __android_log_write(prio: i32, tag: *const libc::c_char, text: *const libc::c_char) -> i32;
}

/// A `tracing` writer that emits to logcat.
///
/// On Android there is no stdout: the default `tracing_subscriber` fmt writer
/// sends every line into a closed file descriptor, so a build that is failing
/// to find the trainer produces exactly no evidence. Routing through liblog
/// puts the same lines under `adb logcat -s BikeControl`.
#[derive(Default, Clone, Copy)]
pub struct Logcat;

impl io::Write for Logcat {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        // Interior NULs cannot reach liblog, and a log line is never worth
        // failing a write over, so they are dropped rather than reported.
        let text = String::from_utf8_lossy(buf);
        let trimmed = text.trim_end();
        if !trimmed.is_empty() {
            if let (Ok(tag), Ok(msg)) = (CString::new(TAG), CString::new(trimmed)) {
                // SAFETY: both pointers are NUL-terminated and outlive the call.
                unsafe { __android_log_write(ANDROID_LOG_INFO, tag.as_ptr(), msg.as_ptr()) };
            }
        }
        Ok(buf.len())
    }

    fn flush(&mut self) -> io::Result<()> {
        Ok(())
    }
}

impl<'a> MakeWriter<'a> for Logcat {
    type Writer = Logcat;

    fn make_writer(&'a self) -> Self::Writer {
        Logcat
    }
}
