//! Wraps Pluggable Transports written in Go, allowing Rust code to embed them directly and start
//! them without having to call `exec()`.

use std::ffi::{c_char, c_int, CStr, CString};
use std::fmt::{self, Display};
use std::net::SocketAddr;
use std::os::fd::RawFd;
use std::sync::{Mutex, Once};
use std::{io, panic};
use tracing::{debug, error, info, warn};

mod sys;

static INIT: Once = Once::new();

type ProtectFn = Box<dyn Fn(RawFd) -> io::Result<()> + Send>;

static PROTECT: Mutex<Option<ProtectFn>> = Mutex::new(None);

unsafe extern "C" fn go_protect_callback(fd: c_int) -> c_int {
    let ret = panic::catch_unwind(|| {
        let protect_fn = PROTECT.lock().expect("protect function poisoned");
        if let Some(ref f) = *protect_fn {
            f(fd)
        } else {
            Ok(())
        }
    });
    match ret {
        Ok(Ok(_)) => 1,
        Ok(Err(e)) => {
            warn!("PT protect() failed: {e}");
            0
        }
        Err(_) => {
            error!("PT protect() panicked!");
            0
        }
    }
}

unsafe extern "C" fn go_logging_callback(log_level: u8, message: *const c_char) {
    let ret = panic::catch_unwind(|| {
        let message = unsafe { CStr::from_ptr(message) };
        let message = message.to_string_lossy();
        // These are the same as those in src-go/logging.go
        match log_level {
            1 => debug!("{}", message),
            2 => info!("{}", message),
            3 => warn!("{}", message),
            4 => error!("{}", message),
            x => error!("(unknown log level {x}) {}", message),
        };
    });
    if ret.is_err() {
        error!("go_logging_callback panicked!");
    }
}

/// Initialise the library, setting up logging and registering pluggable transports in the Go
/// registry.
///
/// This function should be called before calling any other functions in this library. If you
/// do not, it will be called for you, and the code will panic if its return value is `Err`.
pub fn init() -> Result<(), String> {
    let mut ret = Ok(());
    INIT.call_once(|| unsafe {
        sys::SetLoggingCallback(Some(go_logging_callback));
        sys::SetProtectCallback(Some(go_protect_callback));
        ret = handle_go_error(sys::InitialiseTransports());
    });
    ret
}

/// Add a callback to protect outgoing connections.
///
/// The callback will be called with the fd of all sockets the pluggable transports create in
/// order to reach the internet.
pub fn set_protect_callback(protect: impl Fn(RawFd) -> io::Result<()> + Send + 'static) {
    *PROTECT.lock().unwrap() = Some(Box::new(protect));
}

/// Handle a returned Go error (a nullable pointer to a C string).
fn handle_go_error(ret: *mut c_char) -> Result<(), String> {
    if ret.is_null() {
        Ok(())
    } else {
        // FIXME(eta): This leaks the string. Freeing it seems complicated and probably requires
        //             calling `libc::free` somehow.
        let cstr = unsafe { CStr::from_ptr(ret) };
        Err(cstr.to_string_lossy().to_string())
    }
}

/// A kind of pluggable transport supported by the library.
pub enum TransportKind {
    MeekLite,
    Obfs2,
    Obfs3,
    Obfs4,
    ScrambleSuit,
}

impl Display for TransportKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            TransportKind::MeekLite => write!(f, "meek_lite"),
            TransportKind::Obfs2 => write!(f, "obfs2"),
            TransportKind::Obfs3 => write!(f, "obfs3"),
            TransportKind::Obfs4 => write!(f, "obfs4"),
            TransportKind::ScrambleSuit => write!(f, "scramblesuit"),
        }
    }
}

/// Start a copy of the transport `kind`, listening on the provided `socks_addr` for incoming SOCKS
/// connections, and storing necessary state in the `state_dir`.
///
/// Returns immediately. The transport runs in a goroutine.
pub fn start_transport(
    kind: TransportKind,
    socks_addr: SocketAddr,
    state_dir: &str,
) -> Result<(), String> {
    init().expect("onionmasq_pt_wrapper::init() failed");

    let transport_name =
        CString::new(kind.to_string()).expect("transport name somehow contained null");
    let socks_addr =
        CString::new(socks_addr.to_string()).expect("socks addr somehow contained null");
    let state_dir =
        CString::new(state_dir).map_err(|_| "state_dir contains a NUL byte".to_string())?;

    handle_go_error(unsafe {
        sys::StartTransport(
            transport_name.as_ptr() as *mut c_char,
            socks_addr.as_ptr() as *mut c_char,
            state_dir.as_ptr() as *mut c_char,
        )
    })?;

    Ok(())
}

#[cfg(test)]
mod test {
    use std::ffi::{c_char, CStr};
    use std::sync::Mutex;

    #[test]
    fn ffi_sanity_check() {
        assert_eq!(unsafe { crate::sys::SanityCheck() }, 42);
    }

    #[test]
    fn logging_test() {
        static RESULTS: Mutex<Option<(u8, String)>> = Mutex::new(None);

        extern "C" fn logging_callback(log_level: u8, message: *const c_char) {
            let message = unsafe { CStr::from_ptr(message) };
            *RESULTS.lock().unwrap() = Some((log_level, message.to_string_lossy().to_string()));
        }

        unsafe {
            crate::sys::LoggingTest(Some(logging_callback));
            assert_eq!(
                RESULTS.lock().unwrap().take(),
                Some((42, "example: lol".into()))
            );
        }
    }
}
