righvalor 0.1.0

RighValor: AI Infrastructure and Applications Framework for the Far Edge
use std::{collections::HashSet, sync::Arc};

use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{debug, error};

/// RighGravity node state as returned by the API
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RighGravityNodeState {
    pub id: String,
    pub ip: Option<String>,
    pub ipv6: Option<HashSet<String>>,
    pub firmware_version: Option<String>,
    pub onboarding_state: String,
}

/// RighGravity location info
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RighGravityLocation {
    pub location_id: String,
}

/// Client for RighGravity REST API
#[derive(Clone)]
pub struct RighGravityClient {
    client: Client,
    base_url: String,
    location_id: Arc<RwLock<Option<String>>>,
}

impl RighGravityClient {
    pub fn new(base_url: impl Into<String>) -> Self {
        Self {
            client: Client::new(),
            base_url: base_url.into(),
            location_id: Arc::new(RwLock::new(None)),
        }
    }

    /// Fetch node states for the current location
    pub async fn fetch_node_states(&self) -> Result<Vec<RighGravityNodeState>> {
        // Auto-fetch location if not set
        let mut location_id = self.location_id.read().await.clone();

        if location_id.is_none() {
            // Fetch locations and use the first one
            let url = format!("{}/api/locations", self.base_url);
            let response = self.client.get(&url).send().await?;

            if response.status().is_success() {
                let locations: Vec<RighGravityLocation> = response.json().await?;
                if !locations.is_empty() {
                    location_id = Some(locations[0].location_id.clone());
                    // Update cached location
                    let mut loc = self.location_id.write().await;
                    *loc = location_id.clone();
                }
            }
        }

        let location_id =
            location_id.ok_or_else(|| anyhow::anyhow!("No location available in RighGravity"))?;

        let url = format!("{}/api/locations/{}/nodes", self.base_url, location_id);

        debug!("Fetching node states from RighGravity: {}", url);

        let response = self.client.get(&url).send().await?;

        if !response.status().is_success() {
            error!("Failed to fetch node states: status={}", response.status());
            return Err(anyhow::anyhow!(
                "Failed to fetch node states: {}",
                response.status()
            ));
        }

        let response_body: serde_json::Value = response.json().await?;
        let nodes: Vec<RighGravityNodeState> = if let Some(nodes_array) = response_body.get("nodes")
        {
            serde_json::from_value(nodes_array.clone())?
        } else {
            // Fallback: try to parse as direct array (for backward compatibility)
            serde_json::from_value(response_body)?
        };

        debug!("Fetched {} node states from RighGravity", nodes.len());

        Ok(nodes)
    }

    /// Check if a node is online based on its onboarding state
    pub fn is_node_online(node: &RighGravityNodeState) -> bool {
        // A node is considered online if it has completed onboarding
        // or is in any state other than waiting for onboarding
        !matches!(
            node.onboarding_state.as_str(),
            "onboarding_wait_onboarding" | "onboarding_disconnected" | "disconnected"
        )
    }

    /// Get node by ID
    pub async fn get_node_by_id(&self, node_id: &str) -> Result<Option<RighGravityNodeState>> {
        let nodes = self.fetch_node_states().await?;

        Ok(nodes.into_iter().find(|node| node.id == node_id))
    }
}