// Continuous performance sampling.
//
// One sample per minute: overall CPU%, memory used / total, top
// process by CPU, top process by memory, uptime, process count.
// Posted in batches to /api/agent/metrics; surfaced on the admin
// device detail page as a 24 h sparkline plus a "live" snapshot card.
//
// Architecture mirrors `login_events`:
//   * One background thread, its own current-thread Tokio runtime.
//   * Sysinfo crate (vendored under hbb_common) does the cross-cutting
//     work — we keep one `System` instance alive across iterations so
//     its per-process CPU% accounting (which is differential against
//     the previous refresh) stays accurate.
//   * In-memory queue with retry-with-backoff and a hard drop cap so
//     a permanently-misconfigured agent can't balloon memory.
//   * Server-side `INSERT OR IGNORE` keyed on (peer_id, at) dedups any
//     retries that get there twice.
//
// Process CPU% on Windows is reported by sysinfo as "% of one core",
// so a busy multi-threaded process can read >100%. We normalise by
// `cpus().len()` so the snapshot card's "top CPU: chrome.exe 18%"
// is comparable to the overall CPU%. Memory is in bytes from sysinfo;
// we convert to MB on the wire.

use anyhow::{anyhow, Result};
use std::sync::Mutex;
use std::time::Duration;

/// Sampling cadence. 60 s strikes a balance between resolution (enough
/// granularity to spot a 2-minute CPU spike) and storage (~1440 rows /
/// device / day on the server side).
const SAMPLE_INTERVAL: Duration = Duration::from_secs(60);

/// Flush cadence on the happy path. The reporter tries to flush after
/// every sample anyway; this is the floor used by the network-error
/// backoff before it doubles.
const FLUSH_INTERVAL_BASE: Duration = Duration::from_secs(60);
const FLUSH_INTERVAL_MAX: Duration = Duration::from_secs(15 * 60);

/// At backoff cap = 15 min, this is ~3 days of trying — enough to ride
/// out a long server-side outage. Beyond that we drop, on the same
/// reasoning as `login_events::DROP_AFTER`.
const DROP_AFTER: u32 = 300;

/// Must match the server's MAX_SAMPLES_PER_POST.
const MAX_SAMPLES_PER_POST: usize = 512;

#[derive(Clone, Debug, Default)]
struct PendingSample {
    at: i64,
    cpu_pct: f64,
    mem_used_mb: i64,
    mem_total_mb: i64,
    proc_count: i64,
    uptime_secs: i64,
    top_cpu_name: String,
    top_cpu_pct: f64,
    top_mem_name: String,
    top_mem_mb: i64,
    attempts: u32,
}

#[cfg(target_os = "windows")]
static QUEUE: Mutex<Vec<PendingSample>> = Mutex::new(Vec::new());

/// Kick off the metrics sampler. Safe to call multiple times — guarded
/// by an `AtomicBool` so a stray second call is a no-op.
pub fn start() {
    #[cfg(not(target_os = "windows"))]
    {
        // Cross-platform stub; the implementation only runs on Windows
        // because that's the only OS hello-agent ships on. Sysinfo is
        // cross-platform, so a future Linux build can drop the cfg-gate
        // and reuse this module unchanged.
    }
    #[cfg(target_os = "windows")]
    {
        use std::sync::atomic::{AtomicBool, Ordering};
        static STARTED: AtomicBool = AtomicBool::new(false);
        if STARTED.swap(true, Ordering::SeqCst) {
            return;
        }
        std::thread::spawn(move || {
            let rt = match tokio::runtime::Builder::new_current_thread()
                .enable_all()
                .build()
            {
                Ok(rt) => rt,
                Err(e) => {
                    log::warn!("perf: build runtime: {e}");
                    return;
                }
            };
            rt.block_on(run_loop());
        });
    }
}

#[cfg(target_os = "windows")]
async fn run_loop() {
    use hbb_common::sysinfo::System;

    // One System instance, refreshed across iterations. Sysinfo's
    // per-process CPU% is computed against the previous refresh, so a
    // throwaway `System::new()` each tick would always read 0%.
    let mut sys = System::new();
    // Prime CPU + processes once so the first real sample isn't NaN.
    // The MINIMUM_CPU_UPDATE_INTERVAL sleep is what sysinfo's docs
    // recommend between back-to-back CPU refreshes; here we have the
    // full SAMPLE_INTERVAL coming up so we just refresh-and-wait.
    sys.refresh_cpu();
    sys.refresh_processes();
    tokio::time::sleep(SAMPLE_INTERVAL).await;

    let mut flush_backoff = FLUSH_INTERVAL_BASE;
    loop {
        if let Some(sample) = collect_sample(&mut sys) {
            QUEUE.lock().unwrap().push(sample);
        }

        match flush_once().await {
            FlushOutcome::Idle | FlushOutcome::AllSent => {
                flush_backoff = FLUSH_INTERVAL_BASE;
            }
            FlushOutcome::Failed => {
                flush_backoff = (flush_backoff * 2).min(FLUSH_INTERVAL_MAX);
            }
        }

        // We always sleep SAMPLE_INTERVAL — the network backoff slows
        // the *retry* of pending events, not observation. Sampling
        // continues at full cadence so a transient outage doesn't punch
        // a hole in the chart on either side of the dropped window.
        tokio::time::sleep(SAMPLE_INTERVAL.min(flush_backoff)).await;
    }
}

#[cfg(target_os = "windows")]
fn collect_sample(sys: &mut hbb_common::sysinfo::System) -> Option<PendingSample> {
    sys.refresh_cpu();
    sys.refresh_memory();
    sys.refresh_processes();

    let at = hbb_common::chrono::Utc::now().timestamp();
    let cpu_pct = sys.global_cpu_info().cpu_usage() as f64;
    // Sysinfo returns bytes on every platform; the server schema stores
    // MB to keep row sizes small at scale. Divide-then-cast bounds
    // arithmetic to u64 territory.
    let mem_total_mb = (sys.total_memory() / 1024 / 1024) as i64;
    let mem_used_mb = (sys.used_memory() / 1024 / 1024) as i64;
    let uptime_secs = sys.uptime() as i64;

    // Per-process CPU% from sysinfo is normalised per core: a 4-thread
    // process pinning 4 cores on a 4-core machine reads 400%. Divide
    // by core count so the snapshot card's number matches the overall
    // CPU% reading (both 0-100 of the whole machine).
    let cpu_count = sys.cpus().len().max(1) as f64;

    let mut top_cpu: Option<(&str, f32)> = None;
    let mut top_mem: Option<(&str, u64)> = None;
    let mut proc_count = 0i64;
    for proc in sys.processes().values() {
        proc_count += 1;
        let name = proc.name();
        // Some kernel-side rows show up with empty names on Windows;
        // skip them so we don't ever render a top-CPU row with no
        // label.
        if name.is_empty() {
            continue;
        }
        let cu = proc.cpu_usage();
        if cu.is_finite() && cu > top_cpu.map(|(_, v)| v).unwrap_or(0.0) {
            top_cpu = Some((name, cu));
        }
        let mu = proc.memory();
        if mu > top_mem.map(|(_, v)| v).unwrap_or(0) {
            top_mem = Some((name, mu));
        }
    }

    let (top_cpu_name, top_cpu_pct) = top_cpu
        .map(|(n, v)| (n.to_string(), (v as f64 / cpu_count).min(100.0)))
        .unwrap_or_default();
    let (top_mem_name, top_mem_mb) = top_mem
        .map(|(n, v)| (n.to_string(), (v / 1024 / 1024) as i64))
        .unwrap_or_default();

    Some(PendingSample {
        at,
        cpu_pct,
        mem_used_mb,
        mem_total_mb,
        proc_count,
        uptime_secs,
        top_cpu_name,
        top_cpu_pct,
        top_mem_name,
        top_mem_mb,
        attempts: 0,
    })
}

#[cfg(target_os = "windows")]
enum FlushOutcome {
    Idle,
    AllSent,
    Failed,
}

#[cfg(target_os = "windows")]
async fn flush_once() -> FlushOutcome {
    let batch: Vec<PendingSample> = {
        let mut q = QUEUE.lock().unwrap();
        if q.is_empty() {
            return FlushOutcome::Idle;
        }
        let take = q.len().min(MAX_SAMPLES_PER_POST);
        q.drain(..take).collect()
    };

    match post_batch(&batch).await {
        Ok(()) => FlushOutcome::AllSent,
        Err(e) => {
            log::warn!(
                "perf: flush of {} sample(s) failed: {e:#}",
                batch.len(),
            );
            let mut requeued: Vec<PendingSample> = batch
                .into_iter()
                .filter_map(|mut s| {
                    s.attempts = s.attempts.saturating_add(1);
                    if s.attempts >= DROP_AFTER {
                        log::warn!(
                            "perf: dropping sample after {} attempts: at={}",
                            s.attempts, s.at,
                        );
                        None
                    } else {
                        Some(s)
                    }
                })
                .collect();
            let mut q = QUEUE.lock().unwrap();
            let tail: Vec<PendingSample> = q.drain(..).collect();
            requeued.extend(tail);
            *q = requeued;
            FlushOutcome::Failed
        }
    }
}

#[cfg(target_os = "windows")]
async fn post_batch(batch: &[PendingSample]) -> Result<()> {
    let api = librustdesk::common::get_api_server(
        hbb_common::config::Config::get_option("api-server"),
        hbb_common::config::Config::get_option("custom-rendezvous-server"),
    );
    if api.is_empty() {
        return Err(anyhow!("no api-server configured yet"));
    }

    let url = format!("{api}/api/agent/metrics");
    let id = hbb_common::config::Config::get_id();
    let uuid = librustdesk::common::encode64(hbb_common::get_uuid());

    let samples: Vec<hbb_common::serde_json::Value> = batch
        .iter()
        .map(|s| {
            hbb_common::serde_json::json!({
                "at":           s.at,
                "cpu_pct":      s.cpu_pct,
                "mem_used_mb":  s.mem_used_mb,
                "mem_total_mb": s.mem_total_mb,
                "proc_count":   s.proc_count,
                "uptime_secs":  s.uptime_secs,
                "top_cpu_name": s.top_cpu_name,
                "top_cpu_pct":  s.top_cpu_pct,
                "top_mem_name": s.top_mem_name,
                "top_mem_mb":   s.top_mem_mb,
            })
        })
        .collect();
    let body = hbb_common::serde_json::json!({
        "id":      id,
        "uuid":    uuid,
        "samples": samples,
    })
    .to_string();

    let headers = librustdesk::hbbs_http::sign::build_signed_headers(
        "POST",
        "/api/agent/metrics",
        body.as_bytes(),
    )
    .unwrap_or_default();

    let resp = librustdesk::common::post_request(url, body, &headers)
        .await
        .map_err(|e| anyhow!("post: {e}"))?;
    let trimmed = resp.trim();
    if trimmed == "OK" || trimmed == "ID_NOT_FOUND" {
        // ID_NOT_FOUND mirrors the unattended_password / login_events
        // contract: server doesn't know the peer yet (rendezvous race
        // on first boot). We drop the batch rather than retry forever;
        // the next sample lands once /api/heartbeat has created the
        // peer row.
        Ok(())
    } else {
        Err(anyhow!("unexpected response: {trimmed}"))
    }
}
