//! High-level wrapper around the FFI layer.
//!
//! `BambuAgent` manages the full lifecycle: load library → create agent →
//! configure → login → connect → subscribe → send/receive messages.

use std::ffi::CString;
use std::os::raw::{c_char, c_int, c_void};
use std::path::Path;
use std::sync::atomic::{AtomicI32, Ordering};
use std::time::Duration;

use crate::callbacks::{self, CallbackState};
use crate::ffi;

/// BambuStudio version we present in X-BBL-Client-Version headers.
///
/// Must match a real BambuStudio release — the cloud API validates this for
/// request signing.  Find the latest at:
/// <https://github.com/bambulab/BambuStudio/blob/master/src/libslic3r/ProjectTask.hpp>
/// (look for `#define BAMBU_NETWORK_PLUGIN_VERSION`).
pub const BAMBU_STUDIO_VERSION: &str = "02.05.00.66";

/// Convert a string to CString, returning an error instead of panicking on null bytes.
fn to_cstring(s: &str) -> Result<CString, String> {
    CString::new(s).map_err(|e| format!("null byte in string: {e}"))
}

/// Print job request parameters.
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct PrintRequest {
    pub device_id: String,
    pub filename: String,
    #[serde(default = "default_project_name")]
    pub project_name: String,
    pub config_filename: Option<String>,
    pub ams_mapping: Option<String>,
    pub ams_mapping2: Option<String>,
    #[serde(default = "default_true")]
    pub bed_leveling: bool,
    #[serde(default = "default_true")]
    pub flow_cali: bool,
    #[serde(default = "default_true")]
    pub vibration_cali: bool,
    #[serde(default)]
    pub timelapse: bool,
    #[serde(default = "default_true")]
    pub use_ams: bool,
}

fn default_project_name() -> String {
    "bambox".into()
}
fn default_true() -> bool {
    true
}

/// Result from a print job.
#[derive(Debug, Clone, serde::Serialize)]
pub struct PrintResult {
    pub result: String,
    pub return_code: i32,
    pub print_result: i32,
    pub device_id: String,
    pub file: String,
}

/// FFI callback for print progress (logged only).
extern "C" fn print_progress_callback(
    stage: std::os::raw::c_int,
    code: std::os::raw::c_int,
    msg: *const std::os::raw::c_char,
    _ctx: *mut std::os::raw::c_void,
) {
    static STAGE_NAMES: &[&str] = &[
        "Create", "Upload", "Waiting", "Sending",
        "Record", "WaitPrinter", "Finished", "ERROR", "Limit",
    ];
    let stage_name = STAGE_NAMES
        .get(stage as usize)
        .unwrap_or(&"?");
    let msg_str = if msg.is_null() {
        ""
    } else {
        unsafe { std::ffi::CStr::from_ptr(msg).to_str().unwrap_or("") }
    };
    tracing::info!(stage = stage_name, code, msg = msg_str, "print progress");
}

/// Credentials loaded from `credentials.toml` or a token JSON file.
#[derive(Debug)]
pub struct Credentials {
    pub token: String,
    pub refresh_token: String,
    pub uid: String,
    pub name: String,
    pub email: String,
}

impl Credentials {
    /// Load from a JSON token file (same format as the C++ bridge).
    pub fn from_token_json(json: &str) -> Result<Self, String> {
        let v: serde_json::Value =
            serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?;
        let token = v["token"]
            .as_str()
            .unwrap_or_default()
            .to_owned();
        if token.is_empty() {
            return Err("no 'token' field in credentials".into());
        }
        Ok(Self {
            token,
            refresh_token: v["refreshToken"].as_str().unwrap_or_default().to_owned(),
            uid: v["uid"].as_str().unwrap_or_default().to_owned(),
            name: v["name"].as_str().unwrap_or_default().to_owned(),
            email: v["email"].as_str().unwrap_or_default().to_owned(),
        })
    }

    /// Load from a TOML credentials file (`~/.config/estampo/credentials.toml`).
    pub fn from_toml(path: &Path) -> Result<Self, String> {
        let text = std::fs::read_to_string(path)
            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
        let doc: toml::Value =
            text.parse().map_err(|e| format!("invalid TOML: {e}"))?;
        let cloud = doc
            .get("cloud")
            .ok_or("no [cloud] section in credentials")?;
        let token = cloud["token"]
            .as_str()
            .unwrap_or_default()
            .to_owned();
        if token.is_empty() {
            return Err("no token in [cloud] section".into());
        }
        Ok(Self {
            token,
            refresh_token: cloud
                .get("refresh_token")
                .and_then(|v| v.as_str())
                .unwrap_or_default()
                .to_owned(),
            uid: cloud
                .get("uid")
                .and_then(|v| v.as_str())
                .unwrap_or_default()
                .to_owned(),
            name: cloud
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or_default()
                .to_owned(),
            email: cloud
                .get("email")
                .and_then(|v| v.as_str())
                .unwrap_or_default()
                .to_owned(),
        })
    }

    /// Build the user JSON blob expected by the .so's `change_user`.
    fn to_user_json(&self) -> String {
        let refresh = if self.refresh_token.is_empty() {
            &self.token
        } else {
            &self.refresh_token
        };
        format!(
            r#"{{"data":{{"token":"{}","refresh_token":"{}","expires_in":"7200","refresh_expires_in":"2592000","user":{{"uid":"{}","name":"{}","account":"{}","avatar":""}}}}}}"#,
            self.token, refresh, self.uid, self.name, self.email,
        )
    }
}

/// High-level agent wrapping the C++ shim + .so library.
///
/// # Single-instance constraint
///
/// Only one `BambuAgent` may exist per process. The C++ shim (`shim.cpp`)
/// stores callback function pointers and context pointers in file-scoped
/// globals, so creating a second agent would silently overwrite the first
/// agent's callbacks and lead to undefined behavior. This is a fundamental
/// limitation of the Bambu networking library's callback registration API.
pub struct BambuAgent {
    agent: *mut c_void,
    // Box to ensure stable address for callback context pointer
    state: Box<CallbackState>,
    /// Base directory for cert/config/log files.
    data_dir: String,
    /// Flag checked by WasCancelledFn during file upload. Set to non-zero to cancel.
    cancel_flag: AtomicI32,
}

// The agent pointer is thread-safe (the .so manages its own locking)
unsafe impl Send for BambuAgent {}

impl BambuAgent {
    /// Determine the base directory for agent data (cert, config, log).
    ///
    /// Prefers the platform cache dir if it contains the cert file,
    /// otherwise falls back to `/tmp/bambu_agent/` (Docker compatibility).
    fn resolve_data_dir() -> String {
        if let Some(cache) = crate::fetch::cache_dir() {
            let cert = cache.join("cert").join("slicer_base64.cer");
            if cert.is_file() {
                return cache.to_string_lossy().into_owned();
            }
        }
        "/tmp/bambu_agent".to_string()
    }

    /// Load the .so library and create an agent.
    pub fn new(lib_path: &str) -> Result<Self, String> {
        let c_path = CString::new(lib_path).map_err(|e| e.to_string())?;
        let ret = unsafe { ffi::bambu_shim_load(c_path.as_ptr()) };
        if ret != 0 {
            let err = unsafe {
                let p = ffi::bambu_shim_load_error();
                if p.is_null() {
                    "unknown error".to_string()
                } else {
                    std::ffi::CStr::from_ptr(p)
                        .to_string_lossy()
                        .into_owned()
                }
            };
            return Err(format!("failed to load library: {err}"));
        }

        // Log symbol resolution stats from the shim
        let resolved = unsafe { ffi::bambu_shim_resolved_count() };
        let expected = unsafe { ffi::bambu_shim_expected_count() };
        if resolved < expected {
            eprintln!(
                "warning: shim resolved {resolved}/{expected} symbols — some features may be unavailable"
            );
        } else {
            eprintln!("shim: resolved all {resolved}/{expected} symbols");
        }

        // Set SSL cert env vars (same as C++ bridge) — needed for uploads
        if std::env::var("CURL_CA_BUNDLE").is_err() {
            std::env::set_var("CURL_CA_BUNDLE", "/etc/ssl/certs/ca-certificates.crt");
        }
        if std::env::var("SSL_CERT_FILE").is_err() {
            std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt");
        }

        let data_dir = Self::resolve_data_dir();

        // Create directories the .so expects
        let _ = std::fs::create_dir_all(format!("{data_dir}/log"));
        let _ = std::fs::create_dir_all(format!("{data_dir}/config"));
        let _ = std::fs::create_dir_all(format!("{data_dir}/cert"));

        let log_dir = CString::new(format!("{data_dir}/log")).expect("literal contains no NUL");
        let agent = unsafe { ffi::bambu_shim_create_agent(log_dir.as_ptr()) };
        if agent.is_null() {
            return Err("create_agent returned null".into());
        }

        let state = Box::new(CallbackState::new());
        let mut this = Self {
            agent,
            state,
            data_dir,
            cancel_flag: AtomicI32::new(0),
        };
        this.configure()?;
        Ok(this)
    }

    /// Set up directories, certs, headers, and register all callbacks.
    fn configure(&mut self) -> Result<(), String> {
        let config_dir = CString::new(format!("{}/config", self.data_dir)).expect("literal contains no NUL");
        let cert_dir = CString::new(format!("{}/cert", self.data_dir)).expect("literal contains no NUL");
        let cert_name = CString::new("slicer_base64.cer").expect("literal contains no NUL");
        let country = CString::new("US").expect("literal contains no NUL");

        unsafe {
            ffi::bambu_shim_init_log(self.agent);
            ffi::bambu_shim_set_config_dir(self.agent, config_dir.as_ptr());
            ffi::bambu_shim_set_cert_file(self.agent, cert_dir.as_ptr(), cert_name.as_ptr());
            ffi::bambu_shim_set_country_code(self.agent, country.as_ptr());
            ffi::bambu_shim_start(self.agent);
        }

        // Set HTTP headers (BambuStudio slicer identity)
        self.set_http_headers()?;

        // Register callbacks
        let ctx = &*self.state as *const CallbackState as *mut c_void;
        unsafe {
            ffi::bambu_shim_set_on_server_connected_fn(
                self.agent,
                callbacks::on_server_connected,
                ctx,
            );
            ffi::bambu_shim_set_on_message_fn(self.agent, callbacks::on_message, ctx);
            ffi::bambu_shim_set_on_printer_connected_fn(
                self.agent,
                callbacks::on_printer_connected,
                ctx,
            );
            ffi::bambu_shim_set_on_user_login_fn(self.agent, callbacks::on_user_login, ctx);
            ffi::bambu_shim_set_on_http_error_fn(self.agent, callbacks::on_http_error, ctx);

            let country_code = CString::new("US").expect("literal contains no NUL");
            ffi::bambu_shim_set_get_country_code_fn(self.agent, country_code.as_ptr());

            ffi::bambu_shim_set_on_subscribe_failure_fn(
                self.agent,
                callbacks::on_subscribe_failure,
                ctx,
            );
        }

        Ok(())
    }

    /// Generate a stable UUID device ID from the machine hostname.
    /// This mimics BambuStudio's slicer_uuid — a persistent identifier
    /// that the cloud API requires for request signing.
    fn stable_device_id() -> String {
        // Use UUID v5 (SHA-1) with the DNS namespace and hostname
        // to produce a deterministic, stable ID per machine.
        let host = hostname::get()
            .map(|h| h.to_string_lossy().into_owned())
            .unwrap_or_else(|_| "bambox-unknown".into());
        uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_DNS, host.as_bytes()).to_string()
    }

    fn set_http_headers(&self) -> Result<(), String> {
        let os_type = if cfg!(target_os = "macos") {
            "macos"
        } else if cfg!(target_os = "windows") {
            "windows"
        } else {
            "linux"
        };

        let keys_owned: Vec<CString> = [
            "X-BBL-Client-Type",
            "X-BBL-Client-Name",
            "X-BBL-Client-Version",
            "X-BBL-OS-Type",
            "X-BBL-OS-Version",
            "X-BBL-Device-ID",
            "X-BBL-Language",
        ]
        .iter()
        .map(|s| CString::new(*s).expect("literal contains no NUL"))
        .collect();

        let device_id = Self::stable_device_id();
        tracing::debug!(device_id = %device_id, os_type, "setting X-BBL HTTP headers");

        let vals_raw: Vec<String> = vec![
            "slicer".into(),
            "BambuStudio".into(),
            BAMBU_STUDIO_VERSION.into(),
            os_type.into(),
            "6.8.0".into(),
            device_id,
            "en".into(),
        ];
        let vals_owned: Vec<CString> = vals_raw
            .iter()
            .map(|s| CString::new(s.as_str()).expect("no NUL in header value"))
            .collect();

        let keys: Vec<*const c_char> = keys_owned.iter().map(|s| s.as_ptr()).collect();
        let vals: Vec<*const c_char> = vals_owned.iter().map(|s| s.as_ptr()).collect();

        unsafe {
            ffi::bambu_shim_set_extra_http_header(
                self.agent,
                keys.as_ptr(),
                vals.as_ptr(),
                keys.len() as i32,
            );
        }
        Ok(())
    }

    /// Log in with credentials and connect to the MQTT server.
    pub fn login_and_connect(&self, creds: &Credentials) -> Result<(), String> {
        let user_json = CString::new(creds.to_user_json()).map_err(|e| e.to_string())?;

        let ret = unsafe { ffi::bambu_shim_change_user(self.agent, user_json.as_ptr()) };
        if ret != 0 {
            return Err(format!("login failed (change_user returned {ret})"));
        }

        // Wait for login callback
        self.poll_flag(&self.state.user_logged_in, Duration::from_secs(2));

        if unsafe { ffi::bambu_shim_is_user_login(self.agent) } == 0 {
            return Err("login did not succeed".into());
        }
        tracing::info!(
            name = creds.name.as_str(),
            email = creds.email.as_str(),
            "logged in"
        );

        // Connect to MQTT
        unsafe { ffi::bambu_shim_connect_server(self.agent) };

        // Wait for server connection
        for _ in 0..150 {
            if self.state.server_connected.load(Ordering::SeqCst) {
                break;
            }
            if unsafe { ffi::bambu_shim_is_server_connected(self.agent) } != 0 {
                self.state.server_connected.store(true, Ordering::SeqCst);
                break;
            }
            std::thread::sleep(Duration::from_millis(100));
        }

        if !self.state.server_connected.load(Ordering::SeqCst) {
            return Err("could not connect to MQTT server".into());
        }
        tracing::info!("MQTT connected");
        Ok(())
    }

    /// Subscribe to a device and send pushall. Returns when the full status
    /// arrives or `timeout` elapses.
    pub fn subscribe_and_pushall(
        &self,
        device_id: &str,
        timeout: Duration,
    ) -> Result<(), String> {
        let dev = CString::new(device_id).map_err(|e| e.to_string())?;
        let module = CString::new("device").expect("literal contains no NUL");

        unsafe {
            ffi::bambu_shim_set_user_selected_machine(self.agent, dev.as_ptr());
        }

        self.state.printer_subscribed.store(false, Ordering::SeqCst);
        unsafe {
            ffi::bambu_shim_start_subscribe(self.agent, module.as_ptr());
        }

        // Wait for subscription callback
        self.poll_flag(&self.state.printer_subscribed, Duration::from_secs(3));

        // Send pushall (retry up to 3 times)
        let pushall = CString::new(
            r#"{"pushing":{"sequence_id":"0","command":"pushall","version":1,"push_target":1}}"#,
        )
        .expect("literal contains no NUL");

        let mut ret;
        for i in 0..3 {
            if i > 0 {
                std::thread::sleep(Duration::from_secs(1));
            }
            ret = unsafe {
                ffi::bambu_shim_send_message(self.agent, dev.as_ptr(), pushall.as_ptr(), 0)
            };
            if ret == 0 {
                break;
            }
            // Try the other send function
            ret = unsafe {
                ffi::bambu_shim_send_message_to_printer(
                    self.agent,
                    dev.as_ptr(),
                    pushall.as_ptr(),
                    0,
                    0,
                )
            };
            if ret == 0 {
                break;
            }
            tracing::debug!(attempt = i + 1, ret, "pushall retry");
        }

        // Wait for messages
        let start = std::time::Instant::now();
        while start.elapsed() < timeout {
            // Check if we got a full status (large message with gcode_state)
            {
                let msgs = self.state.messages.lock().unwrap();
                if msgs
                    .iter()
                    .any(|m| m.payload.len() > 500 && m.payload.contains("gcode_state"))
                {
                    // Give a brief grace period for remaining messages
                    drop(msgs);
                    std::thread::sleep(Duration::from_millis(300));
                    break;
                }
            }
            std::thread::sleep(Duration::from_millis(100));
        }

        Ok(())
    }

    /// Send an MQTT message to a device via cloud (QoS 1).
    ///
    /// Uses `send_message` (cloud path) matching BambuStudio's
    /// `cloud_publish_json`.  Falls back to `send_message_to_printer`
    /// (LAN path) only if the cloud call fails.
    pub fn send_message(&self, device_id: &str, json: &str) -> Result<i32, String> {
        let dev = to_cstring(device_id)?;
        let msg = to_cstring(json)?;
        // QoS 1 — BambuStudio sends all print commands at QoS 1
        let mut ret =
            unsafe { ffi::bambu_shim_send_message(self.agent, dev.as_ptr(), msg.as_ptr(), 1) };
        if ret != 0 {
            tracing::debug!(ret, "send_message (cloud) failed, trying send_message_to_printer");
            ret = unsafe {
                ffi::bambu_shim_send_message_to_printer(
                    self.agent,
                    dev.as_ptr(),
                    msg.as_ptr(),
                    1,
                    0,
                )
            };
        }
        Ok(ret)
    }

    /// Start a cloud print job. Subscribes, sends pushall, then calls start_print.
    /// Retries up to 5 times on enc-flag-not-ready (-3140).
    pub fn start_print(
        &self,
        params: &PrintRequest,
    ) -> Result<PrintResult, String> {
        // Subscribe and wait for MQTT readiness
        self.subscribe_and_pushall(&params.device_id, Duration::from_secs(20))?;

        // Build C-compatible params — keep CStrings alive for the duration
        let dev_id = to_cstring(&params.device_id)?;
        let task_name = CString::new("").expect("literal contains no NUL");
        let project_name = to_cstring(&params.project_name)?;
        let preset_name = CString::new("").expect("literal contains no NUL");
        let filename = to_cstring(&params.filename)?;
        let config_filename = to_cstring(params.config_filename.as_deref().unwrap_or(""))?;
        let ftp_folder = CString::new("sdcard/").expect("literal contains no NUL");
        let ams_mapping = to_cstring(params.ams_mapping.as_deref().unwrap_or("[0,1,2,3]"))?;
        let ams_mapping2 = to_cstring(params.ams_mapping2.as_deref().unwrap_or(""))?;
        let ams_mapping_info = CString::new("").expect("literal contains no NUL");
        let connection_type = CString::new("cloud").expect("literal contains no NUL");
        let print_type = CString::new("from_normal").expect("literal contains no NUL");
        let bed_type = CString::new("auto").expect("literal contains no NUL");

        let shim_params = ffi::ShimPrintParams {
            dev_id: dev_id.as_ptr(),
            task_name: task_name.as_ptr(),
            project_name: project_name.as_ptr(),
            preset_name: preset_name.as_ptr(),
            filename: filename.as_ptr(),
            config_filename: config_filename.as_ptr(),
            plate_index: 1,
            ftp_folder: ftp_folder.as_ptr(),
            ams_mapping: ams_mapping.as_ptr(),
            ams_mapping2: ams_mapping2.as_ptr(),
            ams_mapping_info: ams_mapping_info.as_ptr(),
            connection_type: connection_type.as_ptr(),
            print_type: print_type.as_ptr(),
            task_bed_leveling: if params.bed_leveling { 1 } else { 0 },
            task_flow_cali: if params.flow_cali { 1 } else { 0 },
            task_vibration_cali: if params.vibration_cali { 1 } else { 0 },
            task_layer_inspect: 0,
            task_record_timelapse: if params.timelapse { 1 } else { 0 },
            task_use_ams: if params.use_ams { 1 } else { 0 },
            task_bed_type: bed_type.as_ptr(),
        };

        let mut result = ffi::ShimPrintResult {
            return_code: -999,
            print_result: -999,
            finished: 0,
        };

        // Reset cancel flag before starting
        self.cancel_flag.store(0, Ordering::SeqCst);

        // Retry on enc flag not ready (-3140)
        for attempt in 0..5 {
            result.print_result = -999;
            result.finished = 0;

            let cancel_ptr = &self.cancel_flag as *const AtomicI32 as *const c_int;
            let ret = unsafe {
                ffi::bambu_shim_start_print(
                    self.agent,
                    &shim_params,
                    print_progress_callback,
                    std::ptr::null_mut(),
                    &mut result,
                    cancel_ptr,
                )
            };

            if ret != 0 {
                return Err(format!("shim_start_print returned {ret}"));
            }

            tracing::info!(attempt = attempt + 1, return_code = result.return_code, "start_print");

            if result.return_code != -3140 {
                break;
            }
            tracing::warn!("enc flag not ready, retrying in 15s...");
            let pushall = r#"{"pushing":{"sequence_id":"0","command":"pushall","version":1,"push_target":1}}"#;
            self.send_message(&params.device_id, pushall)?;
            std::thread::sleep(Duration::from_secs(15));
        }

        let status = match result.return_code {
            0 => "success",
            -1 => "sent",
            _ => "error",
        };

        Ok(PrintResult {
            result: status.into(),
            return_code: result.return_code,
            print_result: result.print_result,
            device_id: params.device_id.clone(),
            file: params.filename.clone(),
        })
    }

    /// Cancel the current in-flight print (if any).
    ///
    /// Sets the atomic cancel flag that the C++ `WasCancelledFn` lambda polls
    /// during file upload. The .so checks this flag periodically and aborts
    /// the upload when it sees a non-zero value.
    pub fn cancel_current_print(&self) {
        self.cancel_flag.store(1, Ordering::SeqCst);
        tracing::info!("cancel flag set");
    }

    /// Drain all buffered MQTT messages.
    pub fn drain_messages(&self) -> Vec<callbacks::MqttMessage> {
        self.state.drain_messages()
    }

    /// Access the callback state directly.
    pub fn callback_state(&self) -> &CallbackState {
        &self.state
    }

    /// Raw agent pointer for direct FFI calls.
    pub fn agent_ptr(&self) -> *mut c_void {
        self.agent
    }

    /// Create a null agent for testing (no FFI calls allowed).
    /// # Safety
    /// Only for tests — calling any FFI method on this agent will crash.
    #[cfg(test)]
    pub unsafe fn test_null() -> Self {
        Self {
            agent: std::ptr::null_mut(),
            state: Box::new(CallbackState::new()),
            data_dir: "/tmp/bambu_agent".to_string(),
            cancel_flag: AtomicI32::new(0),
        }
    }

    /// Poll an atomic bool flag until it becomes true or timeout elapses.
    fn poll_flag(&self, flag: &std::sync::atomic::AtomicBool, timeout: Duration) {
        let start = std::time::Instant::now();
        while start.elapsed() < timeout && !flag.load(Ordering::SeqCst) {
            std::thread::sleep(Duration::from_millis(50));
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn credentials_from_token_json_valid() {
        let json = r#"{"token":"abc123","refreshToken":"ref456","uid":"42","name":"Test","email":"t@t.com"}"#;
        let c = Credentials::from_token_json(json).unwrap();
        assert_eq!(c.token, "abc123");
        assert_eq!(c.refresh_token, "ref456");
        assert_eq!(c.uid, "42");
        assert_eq!(c.name, "Test");
        assert_eq!(c.email, "t@t.com");
    }

    #[test]
    fn credentials_from_token_json_missing_token() {
        let json = r#"{"uid":"42"}"#;
        let err = Credentials::from_token_json(json).unwrap_err();
        assert!(err.contains("token"), "expected token error, got: {err}");
    }

    #[test]
    fn credentials_from_token_json_invalid() {
        let err = Credentials::from_token_json("not json").unwrap_err();
        assert!(err.contains("invalid JSON"));
    }

    #[test]
    fn credentials_from_toml_valid() {
        let dir = std::env::temp_dir().join("bambu_test_creds");
        let _ = std::fs::create_dir_all(&dir);
        let path = dir.join("credentials.toml");
        std::fs::write(
            &path,
            r#"
[cloud]
token = "tok123"
refresh_token = "ref789"
uid = "99"
email = "user@example.com"
"#,
        )
        .unwrap();

        let c = Credentials::from_toml(&path).unwrap();
        assert_eq!(c.token, "tok123");
        assert_eq!(c.refresh_token, "ref789");
        assert_eq!(c.uid, "99");
        assert_eq!(c.email, "user@example.com");

        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn credentials_from_toml_no_cloud_section() {
        let dir = std::env::temp_dir().join("bambu_test_creds2");
        let _ = std::fs::create_dir_all(&dir);
        let path = dir.join("bad.toml");
        std::fs::write(&path, "[other]\nfoo = \"bar\"\n").unwrap();

        let err = Credentials::from_toml(&path).unwrap_err();
        assert!(err.contains("cloud"), "expected cloud error, got: {err}");

        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn credentials_to_user_json_structure() {
        let c = Credentials {
            token: "t".into(),
            refresh_token: "r".into(),
            uid: "u".into(),
            name: "n".into(),
            email: "e".into(),
        };
        let json = c.to_user_json();
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(v["data"]["token"], "t");
        assert_eq!(v["data"]["refresh_token"], "r");
        assert_eq!(v["data"]["user"]["uid"], "u");
        assert_eq!(v["data"]["user"]["name"], "n");
        assert_eq!(v["data"]["user"]["account"], "e");
    }

    #[test]
    fn print_request_deserialize_minimal() {
        let json = r#"{"device_id":"DEV1","filename":"test.3mf"}"#;
        let req: PrintRequest = serde_json::from_str(json).unwrap();
        assert_eq!(req.device_id, "DEV1");
        assert_eq!(req.filename, "test.3mf");
        assert_eq!(req.project_name, "bambox");
        assert!(req.bed_leveling);
        assert!(req.flow_cali);
        assert!(req.vibration_cali);
        assert!(!req.timelapse);
        assert!(req.use_ams);
        assert!(req.ams_mapping.is_none());
        assert!(req.config_filename.is_none());
    }

    #[test]
    fn print_request_deserialize_full() {
        let json = r#"{
            "device_id": "DEV1",
            "filename": "cube.3mf",
            "project_name": "my-project",
            "config_filename": "cube_config.3mf",
            "ams_mapping": "[0,1]",
            "ams_mapping2": "[{\"ams_id\":0,\"slot_id\":0}]",
            "bed_leveling": false,
            "flow_cali": false,
            "vibration_cali": false,
            "timelapse": true,
            "use_ams": false
        }"#;
        let req: PrintRequest = serde_json::from_str(json).unwrap();
        assert_eq!(req.project_name, "my-project");
        assert_eq!(req.config_filename.as_deref(), Some("cube_config.3mf"));
        assert_eq!(req.ams_mapping.as_deref(), Some("[0,1]"));
        assert!(!req.bed_leveling);
        assert!(req.timelapse);
        assert!(!req.use_ams);
    }

    #[test]
    fn print_request_serialize_roundtrip() {
        let req = PrintRequest {
            device_id: "DEV".into(),
            filename: "f.3mf".into(),
            project_name: "p".into(),
            config_filename: None,
            ams_mapping: None,
            ams_mapping2: None,
            bed_leveling: true,
            flow_cali: true,
            vibration_cali: true,
            timelapse: false,
            use_ams: true,
        };
        let json = serde_json::to_string(&req).unwrap();
        let req2: PrintRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(req2.device_id, "DEV");
        assert_eq!(req2.filename, "f.3mf");
    }

    #[test]
    fn credentials_to_user_json_empty_refresh_falls_back_to_token() {
        let c = Credentials {
            token: "mytoken".into(),
            refresh_token: "".into(),
            uid: "".into(),
            name: "".into(),
            email: "".into(),
        };
        let json = c.to_user_json();
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(v["data"]["refresh_token"], "mytoken");
    }

    #[test]
    fn test_null_agent_construction() {
        let agent = unsafe { BambuAgent::test_null() };
        assert!(agent.agent_ptr().is_null());
        assert_eq!(agent.data_dir, "/tmp/bambu_agent");
    }

    #[test]
    fn test_null_agent_callback_state_accessible() {
        let agent = unsafe { BambuAgent::test_null() };
        let state = agent.callback_state();
        assert!(!state.server_connected.load(std::sync::atomic::Ordering::SeqCst));
        assert!(!state.user_logged_in.load(std::sync::atomic::Ordering::SeqCst));
        assert!(!state.printer_subscribed.load(std::sync::atomic::Ordering::SeqCst));
    }

    #[test]
    fn test_null_agent_drain_messages_empty() {
        let agent = unsafe { BambuAgent::test_null() };
        let msgs = agent.drain_messages();
        assert!(msgs.is_empty());
    }

    #[test]
    fn to_cstring_valid() {
        let result = super::to_cstring("hello");
        assert!(result.is_ok());
        assert_eq!(result.unwrap().to_str().unwrap(), "hello");
    }

    #[test]
    fn to_cstring_with_null_byte_fails() {
        let result = super::to_cstring("hello\0world");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("null byte"));
    }

    #[test]
    fn to_cstring_empty_ok() {
        let result = super::to_cstring("");
        assert!(result.is_ok());
    }

    #[test]
    fn print_result_serialize() {
        let result = PrintResult {
            result: "success".into(),
            return_code: 0,
            print_result: 0,
            device_id: "DEV001".into(),
            file: "test.3mf".into(),
        };
        let json = serde_json::to_string(&result).unwrap();
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(v["result"], "success");
        assert_eq!(v["return_code"], 0);
        assert_eq!(v["device_id"], "DEV001");
    }

    #[test]
    fn credentials_from_toml_empty_token_fails() {
        let dir = std::env::temp_dir().join("bambu_test_empty_tok");
        let _ = std::fs::create_dir_all(&dir);
        let path = dir.join("empty_token.toml");
        std::fs::write(
            &path,
            "[cloud]\ntoken = \"\"\n",
        )
        .unwrap();

        let err = Credentials::from_toml(&path).unwrap_err();
        assert!(err.contains("token"), "expected token error, got: {err}");
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn credentials_from_toml_missing_file() {
        let path = std::path::PathBuf::from("/tmp/nonexistent_creds_12345.toml");
        let err = Credentials::from_toml(&path).unwrap_err();
        assert!(err.contains("cannot read"));
    }

    #[test]
    fn cancel_flag_initially_zero() {
        let agent = unsafe { BambuAgent::test_null() };
        assert_eq!(agent.cancel_flag.load(Ordering::SeqCst), 0);
    }

    #[test]
    fn cancel_sets_flag() {
        let agent = unsafe { BambuAgent::test_null() };
        agent.cancel_current_print();
        assert_eq!(agent.cancel_flag.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn credentials_from_toml_invalid_toml() {
        let dir = std::env::temp_dir().join("bambu_test_bad_toml");
        let _ = std::fs::create_dir_all(&dir);
        let path = dir.join("bad.toml");
        std::fs::write(&path, "not valid toml {{{").unwrap();

        let err = Credentials::from_toml(&path).unwrap_err();
        assert!(err.contains("invalid TOML"));
        let _ = std::fs::remove_file(&path);
    }

    // --- X-BBL header correctness tests ---
    //
    // These exist because we once shipped fabricated header values that the
    // cloud API silently accepted — until Bambu turned on signing enforcement
    // and every print request started returning -26.

    #[test]
    fn stable_device_id_is_valid_uuid() {
        let id = BambuAgent::stable_device_id();
        assert!(
            uuid::Uuid::parse_str(&id).is_ok(),
            "stable_device_id must be a valid UUID, got: {id}"
        );
    }

    #[test]
    fn stable_device_id_is_deterministic() {
        let a = BambuAgent::stable_device_id();
        let b = BambuAgent::stable_device_id();
        assert_eq!(a, b, "device ID must be stable across calls");
    }

    #[test]
    fn bambu_studio_version_matches_expected_format() {
        // Format: NN.NN.NN.NN — four dot-separated groups of two digits.
        let parts: Vec<&str> = BAMBU_STUDIO_VERSION.split('.').collect();
        assert_eq!(
            parts.len(),
            4,
            "version must have 4 parts: {BAMBU_STUDIO_VERSION}"
        );
        for part in &parts {
            assert!(
                part.len() == 2 && part.chars().all(|c| c.is_ascii_digit()),
                "each version part must be 2 digits, got '{part}' in {BAMBU_STUDIO_VERSION}"
            );
        }
    }
}

impl Drop for BambuAgent {
    fn drop(&mut self) {
        // Note: destroy_agent can hang waiting for MQTT threads.
        // We still try, but the caller may want to use process::exit() instead.
        if !self.agent.is_null() {
            unsafe {
                ffi::bambu_shim_destroy_agent(self.agent);
            }
        }
    }
}
