sunox 0.0.15

Generate AI music from your terminal via direct Suno web workflows
use std::future::Future;
use std::time::{Duration, Instant};

use serde::Serialize;
use tokio::net::{TcpStream, lookup_host};
use tokio::time::timeout;

use crate::app::AppContext;
use crate::core::CliError;
use crate::output::{self, OutputFormat};

const PROBE_TIMEOUT: Duration = Duration::from_secs(10);

#[derive(Serialize)]
struct NetworkReport {
    ok: bool,
    targets: Vec<NetworkTarget>,
}

#[derive(Serialize)]
struct NetworkTarget {
    name: &'static str,
    host: &'static str,
    dns: ProbeStage,
    tcp: ProbeStage,
    https: ProbeStage,
}

#[derive(Serialize)]
struct ProbeStage {
    ok: bool,
    latency_ms: u128,
    #[serde(skip_serializing_if = "Option::is_none")]
    status: Option<u16>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<String>,
}

pub async fn network(ctx: &AppContext) -> Result<(), CliError> {
    let client = reqwest::Client::builder()
        .connect_timeout(PROBE_TIMEOUT)
        .timeout(PROBE_TIMEOUT)
        .build()
        .map_err(|error| CliError::Config(format!("network diagnostic client: {error}")))?;
    let (auth, api) = tokio::join!(
        probe_target(
            &client,
            "auth",
            "auth.suno.com",
            "https://auth.suno.com/v1/client"
        ),
        probe_target(
            &client,
            "api",
            "studio-api-prod.suno.com",
            "https://studio-api-prod.suno.com/api/billing/info/",
        )
    );
    let targets = vec![auth, api];
    let report = NetworkReport {
        ok: network_usable(&targets),
        targets,
    };

    match ctx.fmt {
        OutputFormat::Json => output::json::success(&report),
        OutputFormat::Table => {
            for target in &report.targets {
                eprintln!(
                    "{} ({}): DNS {}, direct TCP {}, HTTPS {}",
                    target.name,
                    target.host,
                    stage_summary(&target.dns),
                    stage_summary(&target.tcp),
                    stage_summary(&target.https),
                );
            }
            if report.ok {
                eprintln!("Network: OK");
            } else {
                eprintln!("Network: degraded — inspect failed stages above");
            }
        }
    }
    Ok(())
}

async fn probe_target(
    client: &reqwest::Client,
    name: &'static str,
    host: &'static str,
    url: &'static str,
) -> NetworkTarget {
    let dns = timed_stage(async {
        let addresses = lookup_host((host, 443))
            .await
            .map_err(|error| error.to_string())?;
        if addresses.count() == 0 {
            return Err("DNS returned no addresses".into());
        }
        Ok(None)
    })
    .await;
    let tcp = timed_stage(async {
        TcpStream::connect((host, 443))
            .await
            .map_err(|error| error.to_string())?;
        Ok(None)
    })
    .await;
    let https = timed_stage(async {
        let response = client
            .get(url)
            .send()
            .await
            .map_err(|error| error.to_string())?;
        Ok(Some(response.status().as_u16()))
    })
    .await;
    NetworkTarget {
        name,
        host,
        dns,
        tcp,
        https,
    }
}

async fn timed_stage<F>(future: F) -> ProbeStage
where
    F: Future<Output = Result<Option<u16>, String>>,
{
    let started = Instant::now();
    match timeout(PROBE_TIMEOUT, future).await {
        Ok(Ok(status)) => ProbeStage {
            ok: true,
            latency_ms: started.elapsed().as_millis(),
            status,
            error: None,
        },
        Ok(Err(error)) => ProbeStage {
            ok: false,
            latency_ms: started.elapsed().as_millis(),
            status: None,
            error: Some(error),
        },
        Err(_) => ProbeStage {
            ok: false,
            latency_ms: started.elapsed().as_millis(),
            status: None,
            error: Some(format!(
                "timed out after {} seconds",
                PROBE_TIMEOUT.as_secs()
            )),
        },
    }
}

fn stage_summary(stage: &ProbeStage) -> String {
    if stage.ok {
        match stage.status {
            Some(status) => format!("OK HTTP {status} ({} ms)", stage.latency_ms),
            None => format!("OK ({} ms)", stage.latency_ms),
        }
    } else {
        format!(
            "FAILED: {}",
            stage.error.as_deref().unwrap_or("unknown error")
        )
    }
}

fn network_usable(targets: &[NetworkTarget]) -> bool {
    targets.iter().all(|target| target.https.ok)
}

#[cfg(test)]
mod tests {
    use super::{NetworkTarget, ProbeStage, network_usable, stage_summary, timed_stage};

    #[test]
    fn stage_summary_includes_http_status() {
        let stage = ProbeStage {
            ok: true,
            latency_ms: 12,
            status: Some(401),
            error: None,
        };

        assert_eq!(stage_summary(&stage), "OK HTTP 401 (12 ms)");
    }

    #[tokio::test]
    async fn timed_stage_preserves_probe_failure() {
        let stage = timed_stage(async { Err("connection refused".into()) }).await;

        assert!(!stage.ok);
        assert_eq!(stage.error.as_deref(), Some("connection refused"));
    }

    #[test]
    fn network_health_follows_the_actual_https_path() {
        let failed = || ProbeStage {
            ok: false,
            latency_ms: 1,
            status: None,
            error: Some("proxy-only network".into()),
        };
        let target = NetworkTarget {
            name: "api",
            host: "example.com",
            dns: failed(),
            tcp: failed(),
            https: ProbeStage {
                ok: true,
                latency_ms: 2,
                status: Some(401),
                error: None,
            },
        };

        assert!(network_usable(&[target]));
    }
}