//! `POST /api/heartbeat` — the agent management loop. The client sends every
//! ~15 s (3 s when active connections exist). The reply may carry, in any
//! combination:
//!   - `sysinfo: true` — force the client to re-upload sysinfo immediately,
//!   - `disconnect: [conn_id, ...]` — tell the client to drop those sessions,
//!   - `modified_at` + `strategy` — push a config-options merge,
//!   - `exec: [{cmd_id, script, max_secs, max_bytes}, ...]` — PowerShell
//!     commands queued from the admin UI. The agent runs each and POSTs
//!     results to `/api/agent/exec-result`. See docs/AGENT-API-AUTH.md.
//!
//! Auth: signed agents (peer.managed=1) must carry `X-RD-Device-Id` +
//! `X-RD-Signature` headers — see `device_auth::verify`. Stock clients
//! (peer.managed=0) keep posting unsigned bodies; the first valid sig we
//! see flips the peer to managed=1 (TOFU).

use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use crate::api::strategy;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;

#[derive(Debug, Deserialize)]
pub struct HeartbeatBody {
    #[serde(default)]
    pub id: String,
    #[serde(default)]
    pub uuid: String,
    #[serde(default)]
    pub ver: i64,
    #[serde(default)]
    pub conns: Option<Vec<i64>>,
    #[serde(default)]
    pub modified_at: i64,
}

#[derive(Debug, Serialize)]
pub struct HeartbeatResp {
    /// Present-and-truthy → client re-uploads sysinfo immediately.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sysinfo: Option<bool>,
    /// Conn IDs the client should drop. Always present (possibly empty).
    pub disconnect: Vec<i64>,
    /// Strategy version. Echoed back by the client; when it changes, the
    /// client re-merges `strategy.config_options` into local config.
    pub modified_at: i64,
    pub strategy: Value,
    /// PowerShell commands queued for this peer. Omitted from the JSON
    /// reply when empty so vanilla rustdesk clients (which don't parse
    /// this field) see a payload that's byte-for-byte identical to what
    /// they received before this feature shipped.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub exec: Vec<ExecRequest>,
}

/// What the agent receives per queued PowerShell command. Caps live on the
/// server so the operator can tune fleet-wide without redeploying agents.
#[derive(Debug, Serialize)]
pub struct ExecRequest {
    pub cmd_id: String,
    pub script: String,
    pub max_secs: u64,
    pub max_bytes: u64,
}

/// Wall-clock ceiling on a single PowerShell exec. Server-side cap; the
/// agent kills the process when the deadline elapses and reports
/// `timed_out=true` to `/api/agent/exec-result`.
const EXEC_MAX_SECS: u64 = 300;
/// Combined stdout+stderr byte ceiling. Past this the agent stops
/// appending and sets `truncated=true`. 1 MiB matches the cap surfaced
/// in the admin UI confirm dialog.
const EXEC_MAX_BYTES: u64 = 1024 * 1024;

pub async fn heartbeat(
    Extension(state): Extension<Arc<AppState>>,
    headers: HeaderMap,
    raw: Bytes,
) -> Result<Json<HeartbeatResp>, ApiError> {
    let outcome = device_auth::verify(&state, "POST", "/api/heartbeat", &headers, &raw).await?;
    let body: HeartbeatBody = serde_json::from_slice(&raw)
        .map_err(|_| ApiError::BadRequest("invalid json".into()))?;
    if body.id.is_empty() || body.uuid.is_empty() {
        return Err(ApiError::BadRequest("id and uuid required".into()));
    }
    match outcome {
        AuthOutcome::Verified { id: signed_id } => {
            if body.id != signed_id {
                return Err(ApiError::Unauthorized);
            }
        }
        AuthOutcome::LegacyUnsigned => {
            device_auth::enforce_managed_for_id(&state, &body.id).await?;
        }
    }
    let conns_json = serde_json::to_string(&body.conns.unwrap_or_default())
        .unwrap_or_else(|_| "[]".into());

    let needs_sysinfo = state
        .db
        .sysinfo_heartbeat(
            &body.id,
            &body.uuid,
            body.ver,
            &conns_json,
            &state.cfg.sysinfo_ver,
        )
        .await?;

    // One-shot operator commands queued for this peer (force-disconnect,
    // force-sysinfo). Read-and-delete in one transaction.
    let mut disconnect: Vec<i64> = vec![];
    let mut force_sysinfo = needs_sysinfo;
    for cmd in state
        .db
        .heartbeat_pop_commands(&body.id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?
    {
        match cmd.kind.as_str() {
            "disconnect" => {
                if let Some(payload) = cmd.payload {
                    if let Ok(arr) = serde_json::from_str::<Vec<i64>>(&payload) {
                        disconnect.extend(arr);
                    }
                }
            }
            "sysinfo" => force_sysinfo = true,
            other => hbb_common::log::warn!("unknown heartbeat_command kind {:?}", other),
        }
    }

    // Strategy resolution (peer > device-group > user, highest priority wins).
    let (modified_at, strategy) = strategy::resolve_for(&state, &body.id).await;

    // Pop any queued PowerShell exec commands for this peer and flip them
    // to 'running' in one transaction. The handler doesn't re-check the
    // strategy gate here — that was done at dispatch time. By the time a
    // row reaches 'queued' status, the operator has already passed all
    // checks; if the strategy changed since dispatch, the rows in flight
    // still ride out their lifecycle (an admin can flip the per-row state
    // via the dashboard if they need to abort, but that's a separate UI
    // surface — see future work in AGENT-API-AUTH.md).
    let exec = state
        .db
        .exec_pop_queued_for_peer(&body.id)
        .await
        .map(|rows| {
            rows.into_iter()
                .map(|r| ExecRequest {
                    cmd_id: r.cmd_id,
                    script: r.script,
                    max_secs: EXEC_MAX_SECS,
                    max_bytes: EXEC_MAX_BYTES,
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_else(|e| {
            hbb_common::log::warn!("exec_pop_queued_for_peer({}) failed: {}", body.id, e);
            Vec::new()
        });

    Ok(Json(HeartbeatResp {
        sysinfo: if force_sysinfo { Some(true) } else { None },
        disconnect,
        modified_at,
        strategy,
        exec,
    }))
}
