//! Per-host deployment profile.
//!
//! v0.5.2: a small, optional bundle of "what should this machine do when
//! it images" attached to either a pinned host binding ([`crate::HostBinding`])
//! or a queued device ([`crate::QueueEntry`]). All three fields are
//! optional and independent:
//!
//! * `auto_hostname` — substituted into the served unattended answer file
//!   (`{{HOSTNAME}}`) so the installer sets the machine name.
//! * `auto_ip` — substituted as `{{IP}}`. OpenPXE is a DHCP **proxy** and
//!   does not hand out leases, so this is applied by the installer as a
//!   static-network directive inside the answer file, not by DHCP.
//! * `unattended_file` — the id of an uploaded file in the unattended
//!   store (Kickstart / Preseed / Autoinstall / Windows answer file). When
//!   set, the boot chain injects the appropriate kernel argument so the
//!   install runs unattended.

use serde::{Deserialize, Serialize};

/// Optional deployment hints carried on a host pin or a queue entry.
///
/// The fields are flattened into `HostBinding` / `QueueEntry` on the wire
/// (so existing JSON stays compatible via `#[serde(default)]`); this type
/// is the in-code bundle the boot chain consumes.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployProfile {
    /// Hostname to set on the imaged machine (`{{HOSTNAME}}`). Empty/None
    /// leaves the installer default.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auto_hostname: Option<String>,
    /// Static IPv4/IPv6 the installer should configure (`{{IP}}`). Stored
    /// as a free-form string — validated lightly at the HTTP layer.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auto_ip: Option<String>,
    /// Id of an uploaded file in the unattended store. Empty/None means
    /// "no unattended install — boot interactively".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub unattended_file: Option<String>,
}

/// Cap on the stored hostname / IP strings — generous for any real value
/// but bounds what an operator can stuff into the JSON.
pub const MAX_PROFILE_FIELD_LEN: usize = 255;

impl DeployProfile {
    /// True when nothing is set — lets call sites skip work entirely.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.auto_hostname.is_none() && self.auto_ip.is_none() && self.unattended_file.is_none()
    }

    /// True when an unattended file is selected (drives boot-chain injection).
    #[must_use]
    pub fn has_unattended(&self) -> bool {
        self.unattended_file
            .as_deref()
            .is_some_and(|s| !s.trim().is_empty())
    }

    /// Normalise: trim every field and collapse empty strings to `None`
    /// so persisted JSON never carries `""` for an unset value.
    #[must_use]
    pub fn normalized(mut self) -> Self {
        fn clean(v: Option<String>) -> Option<String> {
            v.map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .map(|s| s.chars().take(MAX_PROFILE_FIELD_LEN).collect())
        }
        self.auto_hostname = clean(self.auto_hostname);
        self.auto_ip = clean(self.auto_ip);
        self.unattended_file = clean(self.unattended_file);
        self
    }
}

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

    #[test]
    fn empty_profile_is_empty() {
        assert!(DeployProfile::default().is_empty());
        assert!(!DeployProfile::default().has_unattended());
    }

    #[test]
    fn normalize_trims_and_nulls_empty() {
        let p = DeployProfile {
            auto_hostname: Some("  node-7  ".into()),
            auto_ip: Some("   ".into()),
            unattended_file: Some(String::new()),
        }
        .normalized();
        assert_eq!(p.auto_hostname.as_deref(), Some("node-7"));
        assert_eq!(p.auto_ip, None);
        assert_eq!(p.unattended_file, None);
        assert!(!p.is_empty());
    }

    #[test]
    fn has_unattended_detects_real_id() {
        let p = DeployProfile {
            unattended_file: Some("ubuntu-ks".into()),
            ..Default::default()
        };
        assert!(p.has_unattended());
    }

    #[test]
    fn long_field_is_capped() {
        let long = "a".repeat(1000);
        let p = DeployProfile {
            auto_hostname: Some(long),
            ..Default::default()
        }
        .normalized();
        assert_eq!(
            p.auto_hostname.as_deref().map(str::len),
            Some(MAX_PROFILE_FIELD_LEN)
        );
    }
}
