use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};

/// Node lifecycle state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeState {
    /// Node registered, awaiting provisioning
    Pending,
    /// Bootstrap in progress
    Provisioning,
    /// Node healthy and serving
    Active,
    /// Node unreachable or unhealthy
    Failed,
    /// Marked for removal
    Draining,
}

impl Default for NodeState {
    fn default() -> Self {
        NodeState::Pending
    }
}

/// Commissioning lifecycle for inventory-driven bare-metal onboarding.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommissionState {
    /// Node has been discovered and reported inventory but not yet approved.
    Discovered,
    /// Manual or automated commissioning is actively validating the node.
    Commissioning,
    /// Inventory has been accepted and the node can be installed or rolled out.
    Commissioned,
}

impl Default for CommissionState {
    fn default() -> Self {
        CommissionState::Discovered
    }
}

/// Installation lifecycle for host provisioning and reprovisioning.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallState {
    /// No install is currently running, but an install may be planned.
    Pending,
    /// Bootstrap or reinstall is actively writing the target system.
    Installing,
    /// The desired system has been installed successfully.
    Installed,
    /// Installation failed and needs operator or controller intervention.
    Failed,
    /// A reinstall has been requested but not started yet.
    ReinstallRequested,
}

impl Default for InstallState {
    fn default() -> Self {
        InstallState::Pending
    }
}

/// Best-effort power state tracked by external management adapters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PowerState {
    On,
    Off,
    Cycling,
    Unknown,
}

impl Default for PowerState {
    fn default() -> Self {
        PowerState::Unknown
    }
}

/// Node information tracked by Deployer
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeInfo {
    /// Unique node identifier (matches cluster-config.json node_id)
    pub id: String,
    /// Machine ID (/etc/machine-id)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub machine_id: Option<String>,
    /// Node hostname
    pub hostname: String,
    /// Node primary IP address
    pub ip: String,
    /// Current lifecycle state
    pub state: NodeState,
    /// SHA256 hash of cluster-config.json for version tracking
    pub cluster_config_hash: String,
    /// Last heartbeat timestamp (UTC)
    pub last_heartbeat: DateTime<Utc>,
    /// Additional metadata (e.g., role, services, hardware info)
    pub metadata: HashMap<String, String>,
}

/// Explicit installation targets returned during bootstrap.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct InstallPlan {
    /// Name of the `nixosConfigurations.<name>` output to install.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub nixos_configuration: Option<String>,
    /// Repository-relative Disko file used during installation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub disko_config_path: Option<String>,
    /// Explicit disk device path used by bootstrap installers.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_disk: Option<String>,
    /// Stable `/dev/disk/by-id/...` selector preferred over volatile device names.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_disk_by_id: Option<String>,
}

impl InstallPlan {
    pub fn merged_with(&self, fallback: Option<&InstallPlan>) -> InstallPlan {
        let mut merged = fallback.cloned().unwrap_or_default();
        if self.nixos_configuration.is_some() {
            merged.nixos_configuration = self.nixos_configuration.clone();
        }
        if self.disko_config_path.is_some() {
            merged.disko_config_path = self.disko_config_path.clone();
        }
        if self.target_disk.is_some() {
            merged.target_disk = self.target_disk.clone();
        }
        if self.target_disk_by_id.is_some() {
            merged.target_disk_by_id = self.target_disk_by_id.clone();
        }
        merged
    }

    pub fn from_layers(
        preferred: Option<&InstallPlan>,
        fallback: Option<&InstallPlan>,
    ) -> Option<InstallPlan> {
        match (preferred, fallback) {
            (Some(preferred), fallback) => Some(preferred.merged_with(fallback)),
            (None, Some(fallback)) => Some(fallback.clone()),
            (None, None) => None,
        }
    }
}

/// Stable node assignment returned by bootstrap enrollment.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct NodeAssignment {
    pub node_id: String,
    pub hostname: String,
    pub role: String,
    pub ip: String,
    #[serde(default)]
    pub labels: HashMap<String, String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pool: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_class: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub failure_domain: Option<String>,
}

/// Bootstrap plan describing how the installer should materialize the node.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct BootstrapPlan {
    #[serde(default)]
    pub services: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub nix_profile: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub install_plan: Option<InstallPlan>,
}

/// Bootstrap credentials and trust material issued for a node.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct BootstrapSecrets {
    #[serde(default)]
    pub ssh_authorized_keys: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ssh_host_key: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tls_cert: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tls_key: Option<String>,
}

/// Canonical bootstrap configuration for a node.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeConfig {
    pub assignment: NodeAssignment,
    #[serde(default)]
    pub bootstrap_plan: BootstrapPlan,
    #[serde(default)]
    pub bootstrap_secrets: BootstrapSecrets,
}

impl NodeConfig {
    pub fn from_parts(
        assignment: NodeAssignment,
        bootstrap_plan: BootstrapPlan,
        bootstrap_secrets: BootstrapSecrets,
    ) -> Self {
        Self {
            assignment,
            bootstrap_plan,
            bootstrap_secrets,
        }
    }
}

/// Basic inventory record for a physical disk observed during commissioning.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DiskFact {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub by_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub size_bytes: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub serial: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rotational: Option<bool>,
}

/// Basic inventory record for a network interface observed during commissioning.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct NicFact {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mac_address: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oper_state: Option<String>,
}

/// DMI strings collected during commissioning.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DmiFact {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vendor: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub product_name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub serial_number: Option<String>,
}

/// Hardware inventory captured during bootstrap / commissioning.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct HardwareFacts {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub architecture: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cpu_model: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cpu_threads: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cpu_cores: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub memory_bytes: Option<u64>,
    #[serde(default)]
    pub disks: Vec<DiskFact>,
    #[serde(default)]
    pub nics: Vec<NicFact>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dmi: Option<DmiFact>,
}

/// Phone Home request payload (machine-id based)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhoneHomeRequest {
    /// Machine ID (/etc/machine-id)
    pub machine_id: String,
    /// Optional: Node identifier if known
    #[serde(skip_serializing_if = "Option::is_none")]
    pub node_id: Option<String>,
    /// Optional: Node hostname
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hostname: Option<String>,
    /// Optional: Node IP address
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ip: Option<String>,
    /// Optional: SHA256 hash of cluster-config.json
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cluster_config_hash: Option<String>,
    /// Node metadata
    #[serde(default)]
    pub metadata: HashMap<String, String>,
    /// Hardware inventory gathered by the bootstrap environment.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hardware_facts: Option<HardwareFacts>,
}

/// Phone Home response payload with secrets
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhoneHomeResponse {
    /// Whether registration was successful
    pub success: bool,
    /// Human-readable message
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    /// Assigned node state
    pub state: NodeState,
    /// Canonical bootstrap configuration returned by the bootstrap API.
    pub node_config: NodeConfig,
}

fn default_max_instances_per_node() -> u32 {
    1
}

fn default_service_replicas() -> u32 {
    1
}

fn default_service_schedule_mode() -> ServiceScheduleMode {
    ServiceScheduleMode::Replicated
}

fn default_rollout_max_unavailable() -> u32 {
    1
}

fn default_rollout_max_surge() -> u32 {
    1
}

fn default_dns_ttl() -> u32 {
    30
}

fn default_service_dependency_condition() -> ServiceDependencyCondition {
    ServiceDependencyCondition::Healthy
}

fn default_service_dependency_min_ready() -> u32 {
    1
}

/// Process specification executed by node-agent for a scheduled instance.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ProcessSpec {
    pub command: String,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default)]
    pub working_dir: Option<String>,
    #[serde(default)]
    pub env: HashMap<String, String>,
}

/// Host port mapping for container execution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ContainerPortSpec {
    pub container_port: u16,
    #[serde(default)]
    pub host_port: Option<u16>,
    #[serde(default)]
    pub protocol: Option<String>,
}

/// Host volume mapping for container execution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ContainerVolumeSpec {
    pub source: String,
    pub target: String,
    #[serde(default)]
    pub read_only: bool,
}

/// Simple container execution specification for node-agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ContainerSpec {
    pub image: String,
    #[serde(default)]
    pub runtime: Option<String>,
    #[serde(default)]
    pub command: Vec<String>,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default)]
    pub env: HashMap<String, String>,
    #[serde(default)]
    pub ports: Vec<ContainerPortSpec>,
    #[serde(default)]
    pub volumes: Vec<ContainerVolumeSpec>,
    #[serde(default)]
    pub network_mode: Option<String>,
    #[serde(default)]
    pub pull_policy: Option<String>,
    #[serde(default)]
    pub working_dir: Option<String>,
}

/// Health check specification executed by node-agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct HealthCheckSpec {
    #[serde(rename = "type")]
    pub check_type: String,
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default)]
    pub interval_secs: Option<u64>,
    #[serde(default)]
    pub timeout_secs: Option<u64>,
    #[serde(default)]
    pub startup_grace_secs: Option<u64>,
}

/// Placement policy for a scheduled service.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlacementPolicy {
    #[serde(default)]
    pub roles: Vec<String>,
    #[serde(default)]
    pub pools: Vec<String>,
    #[serde(default)]
    pub node_classes: Vec<String>,
    #[serde(default)]
    pub match_labels: HashMap<String, String>,
    #[serde(default)]
    pub spread_by_label: Option<String>,
    #[serde(default = "default_max_instances_per_node")]
    pub max_instances_per_node: u32,
}

impl Default for PlacementPolicy {
    fn default() -> Self {
        Self {
            roles: Vec::new(),
            pools: Vec::new(),
            node_classes: Vec::new(),
            match_labels: HashMap::new(),
            spread_by_label: None,
            max_instances_per_node: default_max_instances_per_node(),
        }
    }
}

/// Rollout policy for scheduler-managed service instances.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RolloutStrategySpec {
    #[serde(default = "default_rollout_max_unavailable")]
    pub max_unavailable: u32,
    #[serde(default = "default_rollout_max_surge")]
    pub max_surge: u32,
}

impl Default for RolloutStrategySpec {
    fn default() -> Self {
        Self {
            max_unavailable: default_rollout_max_unavailable(),
            max_surge: default_rollout_max_surge(),
        }
    }
}

/// Scheduling mode for native runtime services.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ServiceScheduleMode {
    /// Keep a fixed replica count and place instances across eligible nodes.
    #[default]
    Replicated,
    /// Run one instance on every eligible node.
    Daemon,
}

/// Scheduler-specific service intent used by the non-Kubernetes fleet scheduler.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServiceScheduleSpec {
    #[serde(default = "default_service_schedule_mode")]
    pub mode: ServiceScheduleMode,
    #[serde(default = "default_service_replicas")]
    pub replicas: u32,
    #[serde(default)]
    pub placement: PlacementPolicy,
    #[serde(default)]
    pub rollout: RolloutStrategySpec,
    #[serde(default)]
    pub instance_port: Option<u16>,
    #[serde(default)]
    pub mesh_port: Option<u16>,
    #[serde(default)]
    pub process: Option<ProcessSpec>,
    #[serde(default)]
    pub container: Option<ContainerSpec>,
    #[serde(default)]
    pub health_check: Option<HealthCheckSpec>,
}

impl Default for ServiceScheduleSpec {
    fn default() -> Self {
        Self {
            mode: default_service_schedule_mode(),
            replicas: default_service_replicas(),
            placement: PlacementPolicy::default(),
            rollout: RolloutStrategySpec::default(),
            instance_port: None,
            mesh_port: None,
            process: None,
            container: None,
            health_check: None,
        }
    }
}

/// DNS publication target selection.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DnsPublishMode {
    /// Publish the resolved load-balancer VIP when available.
    #[default]
    LoadBalancer,
    /// Publish the first healthy instance IP directly.
    Direct,
    /// Publish all healthy instance IPs directly as a DNS record set.
    DirectMulti,
}

/// Desired DNS publication for a service.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DnsPublicationSpec {
    pub zone: String,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default = "default_dns_ttl")]
    pub ttl: u32,
    #[serde(default)]
    pub mode: DnsPublishMode,
}

/// Desired FiberLB publication for a service.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LoadBalancerPublicationSpec {
    #[serde(default)]
    pub org_id: Option<String>,
    #[serde(default)]
    pub project_id: Option<String>,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub listener_port: Option<u16>,
    #[serde(default)]
    pub protocol: Option<String>,
    #[serde(default)]
    pub pool_protocol: Option<String>,
}

/// Desired service publication through UltraCloud network components.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ServicePublicationSpec {
    #[serde(default)]
    pub org_id: Option<String>,
    #[serde(default)]
    pub project_id: Option<String>,
    #[serde(default)]
    pub dns: Option<DnsPublicationSpec>,
    #[serde(default)]
    pub load_balancer: Option<LoadBalancerPublicationSpec>,
}

/// Readiness condition required from another service before this service can reconcile.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ServiceDependencyCondition {
    /// Require a minimum number of healthy dependency instances.
    #[default]
    Healthy,
    /// Require the dependency to publish at least one DNS or load-balancer artifact.
    Published,
}

/// Dependency edge between scheduler-managed services.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServiceDependencySpec {
    pub service: String,
    #[serde(default = "default_service_dependency_condition")]
    pub condition: ServiceDependencyCondition,
    #[serde(default = "default_service_dependency_min_ready")]
    pub min_ready: u32,
}

impl Default for ServiceDependencySpec {
    fn default() -> Self {
        Self {
            service: String::new(),
            condition: default_service_dependency_condition(),
            min_ready: default_service_dependency_min_ready(),
        }
    }
}

/// Cluster node record stored under ultracloud/clusters/{cluster_id}/nodes/{node_id}.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ClusterNodeRecord {
    pub node_id: String,
    #[serde(default)]
    pub machine_id: Option<String>,
    pub ip: String,
    pub hostname: String,
    #[serde(default)]
    pub roles: Vec<String>,
    #[serde(default)]
    pub labels: HashMap<String, String>,
    #[serde(default)]
    pub pool: Option<String>,
    #[serde(default)]
    pub node_class: Option<String>,
    #[serde(default)]
    pub failure_domain: Option<String>,
    #[serde(default)]
    pub nix_profile: Option<String>,
    #[serde(default)]
    pub install_plan: Option<InstallPlan>,
    #[serde(default)]
    pub hardware_facts: Option<HardwareFacts>,
    #[serde(default)]
    pub state: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commission_state: Option<CommissionState>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub install_state: Option<InstallState>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commissioned_at: Option<DateTime<Utc>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_inventory_hash: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub power_state: Option<PowerState>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bmc_ref: Option<String>,
    #[serde(default)]
    pub last_heartbeat: Option<DateTime<Utc>>,
}

/// Node-local NixOS reconciliation status reported by nix-agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ObservedSystemState {
    pub node_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub nixos_configuration: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub flake_root: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_system: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub configured_system: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub current_system: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub booted_system: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rollback_system: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub switch_action: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reboot_required: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_attempt: Option<DateTime<Utc>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_success: Option<DateTime<Utc>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_error: Option<String>,
}

/// Desired NixOS system state for a specific node.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DesiredSystemSpec {
    #[serde(default)]
    pub node_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deployment_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub nixos_configuration: Option<String>,
    /// Optional prebuilt NixOS system closure path to activate directly.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_system: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub flake_ref: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub switch_action: Option<String>,
    #[serde(default)]
    pub health_check_command: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rollback_on_failure: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub drain_before_apply: Option<bool>,
}

/// Cluster metadata (UltraCloud scope).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ClusterSpec {
    pub cluster_id: String,
    #[serde(default)]
    pub environment: Option<String>,
}

/// Node definition used for bootstrap and inventory.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeSpec {
    pub node_id: String,
    #[serde(default)]
    pub machine_id: Option<String>,
    pub hostname: String,
    pub ip: String,
    #[serde(default)]
    pub roles: Vec<String>,
    #[serde(default)]
    pub labels: HashMap<String, String>,
    #[serde(default)]
    pub pool: Option<String>,
    #[serde(default)]
    pub node_class: Option<String>,
    #[serde(default)]
    pub failure_domain: Option<String>,
    #[serde(default)]
    pub nix_profile: Option<String>,
    #[serde(default)]
    pub install_plan: Option<InstallPlan>,
    #[serde(default)]
    pub hardware_facts: Option<HardwareFacts>,
    #[serde(default)]
    pub desired_system: Option<DesiredSystemSpec>,
    #[serde(default)]
    pub state: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commission_state: Option<CommissionState>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub install_state: Option<InstallState>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commissioned_at: Option<DateTime<Utc>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_inventory_hash: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub power_state: Option<PowerState>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bmc_ref: Option<String>,
    #[serde(default)]
    pub last_heartbeat: Option<DateTime<Utc>>,
}

/// Node class definition for reusable bare-metal personas.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeClassSpec {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub nix_profile: Option<String>,
    #[serde(default)]
    pub install_plan: Option<InstallPlan>,
    #[serde(default)]
    pub roles: Vec<String>,
    #[serde(default)]
    pub labels: HashMap<String, String>,
}

/// Pool definition for groups of nodes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodePoolSpec {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub node_class: Option<String>,
    #[serde(default)]
    pub min_size: Option<u32>,
    #[serde(default)]
    pub max_size: Option<u32>,
    #[serde(default)]
    pub labels: HashMap<String, String>,
}

/// Enrollment rule for auto-classifying unknown machines during bootstrap.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnrollmentRuleSpec {
    pub name: String,
    #[serde(default)]
    pub priority: i32,
    #[serde(default)]
    pub match_labels: HashMap<String, String>,
    #[serde(default)]
    pub match_hostname_prefix: Option<String>,
    #[serde(default)]
    pub match_ip_prefixes: Vec<String>,
    #[serde(default)]
    pub pool: Option<String>,
    #[serde(default)]
    pub node_class: Option<String>,
    #[serde(default)]
    pub role: Option<String>,
    #[serde(default)]
    pub labels: HashMap<String, String>,
    #[serde(default)]
    pub nix_profile: Option<String>,
    #[serde(default)]
    pub install_plan: Option<InstallPlan>,
    #[serde(default)]
    pub services: Vec<String>,
    #[serde(default)]
    pub ssh_authorized_keys: Vec<String>,
    #[serde(default)]
    pub node_id_prefix: Option<String>,
}

/// Selector used by host deployments to target bare-metal nodes declaratively.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct HostDeploymentSelector {
    #[serde(default)]
    pub node_ids: Vec<String>,
    #[serde(default)]
    pub roles: Vec<String>,
    #[serde(default)]
    pub pools: Vec<String>,
    #[serde(default)]
    pub node_classes: Vec<String>,
    #[serde(default)]
    pub match_labels: HashMap<String, String>,
}

/// Declarative rollout intent for host-level NixOS updates.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HostDeploymentSpec {
    pub name: String,
    #[serde(default)]
    pub selector: HostDeploymentSelector,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub nixos_configuration: Option<String>,
    /// Optional prebuilt NixOS system closure path handed directly to nix-agent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_system: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub flake_ref: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub batch_size: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_unavailable: Option<u32>,
    #[serde(default)]
    pub health_check_command: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub switch_action: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rollback_on_failure: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub drain_before_apply: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reboot_policy: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub paused: Option<bool>,
}

/// Controller-observed rollout state for a host deployment.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct HostDeploymentStatus {
    #[serde(default)]
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub phase: Option<String>,
    #[serde(default)]
    pub paused: bool,
    #[serde(default)]
    pub paused_by_operator: bool,
    #[serde(default)]
    pub selected_nodes: Vec<String>,
    #[serde(default)]
    pub completed_nodes: Vec<String>,
    #[serde(default)]
    pub in_progress_nodes: Vec<String>,
    #[serde(default)]
    pub failed_nodes: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated_at: Option<DateTime<Utc>>,
}

/// Service ports for logical service definitions.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServicePorts {
    #[serde(default)]
    pub http: Option<u16>,
    #[serde(default)]
    pub grpc: Option<u16>,
}

/// Logical service definition.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServiceSpec {
    pub name: String,
    #[serde(default)]
    pub ports: Option<ServicePorts>,
    #[serde(default)]
    pub protocol: Option<String>,
    #[serde(default)]
    pub mtls_required: Option<bool>,
    #[serde(default)]
    pub mesh_mode: Option<String>,
    #[serde(default)]
    pub depends_on: Vec<ServiceDependencySpec>,
    #[serde(default)]
    pub schedule: Option<ServiceScheduleSpec>,
    #[serde(default)]
    pub publish: Option<ServicePublicationSpec>,
}

/// Service instance definition and observed state.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServiceInstanceSpec {
    pub instance_id: String,
    pub service: String,
    pub node_id: String,
    pub ip: String,
    pub port: u16,
    #[serde(default)]
    pub mesh_port: Option<u16>,
    #[serde(default)]
    pub version: Option<String>,
    #[serde(default)]
    pub health_check: Option<HealthCheckSpec>,
    #[serde(default)]
    pub process: Option<ProcessSpec>,
    #[serde(default)]
    pub container: Option<ContainerSpec>,
    #[serde(default)]
    pub managed_by: Option<String>,
    #[serde(default)]
    pub state: Option<String>,
    #[serde(default)]
    pub last_heartbeat: Option<DateTime<Utc>>,
    #[serde(default)]
    pub observed_at: Option<DateTime<Utc>>,
}

/// Published load-balancer resources for a service.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PublishedLoadBalancerState {
    pub id: String,
    pub pool_id: String,
    pub listener_id: String,
    #[serde(default)]
    pub vip_address: Option<String>,
}

/// Published DNS record for a service.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PublishedDnsRecordState {
    pub zone_id: String,
    pub record_ids: Vec<String>,
    pub fqdn: String,
    pub values: Vec<String>,
}

/// Observed publication state stored separately from ServiceSpec desired state.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServicePublicationState {
    pub service: String,
    pub org_id: String,
    pub project_id: String,
    #[serde(default)]
    pub load_balancer: Option<PublishedLoadBalancerState>,
    #[serde(default)]
    pub dns: Option<PublishedDnsRecordState>,
    #[serde(default)]
    pub observed_at: Option<DateTime<Utc>>,
}

/// Scheduler-observed readiness and dependency state for a logical service.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ServiceStatusRecord {
    pub service: String,
    #[serde(default)]
    pub phase: String,
    #[serde(default)]
    pub desired_instances: u32,
    #[serde(default)]
    pub scheduled_instances: u32,
    #[serde(default)]
    pub healthy_instances: u32,
    #[serde(default)]
    pub publish_ready: bool,
    #[serde(default)]
    pub dependencies_ready: bool,
    #[serde(default)]
    pub blockers: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub observed_at: Option<DateTime<Utc>>,
}

/// Summarized readiness of a service's declared dependencies.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ServiceDependencySummary {
    pub dependencies_ready: bool,
    pub blockers: Vec<String>,
}

pub fn cluster_node_pool(node: &ClusterNodeRecord) -> Option<&str> {
    node.pool
        .as_deref()
        .or_else(|| node.labels.get("pool").map(String::as_str))
        .or_else(|| {
            node.labels
                .get("pool.ultracloud.io/name")
                .map(String::as_str)
        })
}

pub fn cluster_node_class(node: &ClusterNodeRecord) -> Option<&str> {
    node.node_class
        .as_deref()
        .or_else(|| node.labels.get("node_class").map(String::as_str))
        .or_else(|| {
            node.labels
                .get("nodeclass.ultracloud.io/name")
                .map(String::as_str)
        })
}

pub fn cluster_node_is_eligible(
    node: &ClusterNodeRecord,
    placement: &PlacementPolicy,
    heartbeat_timeout_secs: u64,
) -> bool {
    if node.state.as_deref() != Some("active") {
        return false;
    }

    if heartbeat_timeout_secs > 0 {
        let Some(last) = node.last_heartbeat else {
            return false;
        };
        let age = Utc::now().signed_duration_since(last).num_seconds();
        if age > heartbeat_timeout_secs as i64 {
            return false;
        }
    }

    if !placement.roles.is_empty()
        && !node
            .roles
            .iter()
            .any(|role| placement.roles.iter().any(|expected| expected == role))
    {
        return false;
    }

    if !placement.pools.is_empty()
        && !cluster_node_pool(node)
            .map(|pool| placement.pools.iter().any(|expected| expected == pool))
            .unwrap_or(false)
    {
        return false;
    }

    if !placement.node_classes.is_empty()
        && !cluster_node_class(node)
            .map(|node_class| {
                placement
                    .node_classes
                    .iter()
                    .any(|expected| expected == node_class)
            })
            .unwrap_or(false)
    {
        return false;
    }

    placement
        .match_labels
        .iter()
        .all(|(key, value)| node.labels.get(key) == Some(value))
}

pub fn eligible_cluster_nodes<'a>(
    nodes: &'a [ClusterNodeRecord],
    placement: &PlacementPolicy,
    heartbeat_timeout_secs: u64,
) -> Vec<&'a ClusterNodeRecord> {
    nodes
        .iter()
        .filter(|node| cluster_node_is_eligible(node, placement, heartbeat_timeout_secs))
        .collect()
}

pub fn service_instance_has_fresh_heartbeat(
    instance: &ServiceInstanceSpec,
    heartbeat_timeout_secs: u64,
) -> bool {
    if heartbeat_timeout_secs == 0 {
        return true;
    }

    let Some(last_heartbeat) = instance.last_heartbeat.or(instance.observed_at) else {
        return false;
    };

    Utc::now()
        .signed_duration_since(last_heartbeat)
        .num_seconds()
        <= heartbeat_timeout_secs as i64
}

pub fn service_instance_is_available(
    instance: &ServiceInstanceSpec,
    heartbeat_timeout_secs: u64,
) -> bool {
    matches!(instance.state.as_deref(), Some("healthy"))
        && service_instance_has_fresh_heartbeat(instance, heartbeat_timeout_secs)
}

pub fn service_publication_is_ready(state: &ServicePublicationState) -> bool {
    state
        .dns
        .as_ref()
        .map(|dns| !dns.values.is_empty())
        .unwrap_or(false)
        || state
            .load_balancer
            .as_ref()
            .and_then(|load_balancer| load_balancer.vip_address.as_ref())
            .is_some()
}

pub fn compute_service_dependency_cycles(services: &[ServiceSpec]) -> HashSet<String> {
    let service_names = services
        .iter()
        .map(|service| service.name.clone())
        .collect::<HashSet<_>>();
    let dependencies = services
        .iter()
        .map(|service| {
            let deps = service
                .depends_on
                .iter()
                .filter(|dependency| service_names.contains(&dependency.service))
                .map(|dependency| dependency.service.clone())
                .collect::<Vec<_>>();
            (service.name.clone(), deps)
        })
        .collect::<HashMap<_, _>>();

    let mut permanent = HashSet::new();
    let mut visiting = Vec::<String>::new();
    let mut cycles = HashSet::new();

    for service in services {
        visit_service_dependency_cycles(
            &service.name,
            &dependencies,
            &mut permanent,
            &mut visiting,
            &mut cycles,
        );
    }

    cycles
}

fn visit_service_dependency_cycles(
    service: &str,
    dependencies: &HashMap<String, Vec<String>>,
    permanent: &mut HashSet<String>,
    visiting: &mut Vec<String>,
    cycles: &mut HashSet<String>,
) {
    if permanent.contains(service) {
        return;
    }

    if let Some(position) = visiting.iter().position(|current| current == service) {
        cycles.extend(visiting[position..].iter().cloned());
        return;
    }

    visiting.push(service.to_string());
    if let Some(depends_on) = dependencies.get(service) {
        for dependency in depends_on {
            visit_service_dependency_cycles(dependency, dependencies, permanent, visiting, cycles);
        }
    }
    visiting.pop();
    permanent.insert(service.to_string());
}

pub fn summarize_service_dependencies(
    service: &ServiceSpec,
    services_by_name: &HashMap<&str, &ServiceSpec>,
    instances_by_service: &HashMap<String, Vec<ServiceInstanceSpec>>,
    publications: &HashMap<String, ServicePublicationState>,
    dependency_cycles: &HashSet<String>,
    heartbeat_timeout_secs: u64,
) -> ServiceDependencySummary {
    let mut blockers = Vec::new();

    if dependency_cycles.contains(&service.name) {
        blockers.push("cyclic dependency graph".to_string());
    }

    for dependency in &service.depends_on {
        if dependency.service == service.name {
            blockers.push(format!(
                "dependency {} points to itself",
                dependency.service
            ));
            continue;
        }

        if !services_by_name.contains_key(dependency.service.as_str()) {
            blockers.push(format!("dependency {} is not defined", dependency.service));
            continue;
        }

        if dependency_cycles.contains(&dependency.service) {
            blockers.push(format!(
                "dependency {} is part of a dependency cycle",
                dependency.service
            ));
            continue;
        }

        match dependency.condition {
            ServiceDependencyCondition::Healthy => {
                let ready = instances_by_service
                    .get(&dependency.service)
                    .map(|instances| {
                        instances
                            .iter()
                            .filter(|instance| {
                                service_instance_is_available(instance, heartbeat_timeout_secs)
                            })
                            .count() as u32
                    })
                    .unwrap_or(0);
                let min_ready = dependency.min_ready.max(1);
                if ready < min_ready {
                    blockers.push(format!(
                        "dependency {} has {ready}/{min_ready} healthy instance(s)",
                        dependency.service
                    ));
                }
            }
            ServiceDependencyCondition::Published => {
                let ready = publications
                    .get(&dependency.service)
                    .map(service_publication_is_ready)
                    .unwrap_or(false);
                if !ready {
                    blockers.push(format!(
                        "dependency {} is not published",
                        dependency.service
                    ));
                }
            }
        }
    }

    ServiceDependencySummary {
        dependencies_ready: blockers.is_empty(),
        blockers,
    }
}

pub fn desired_service_instance_count(
    service: &ServiceSpec,
    nodes: &[ClusterNodeRecord],
    heartbeat_timeout_secs: u64,
) -> u32 {
    let Some(schedule) = service.schedule.as_ref() else {
        return 0;
    };
    match schedule.mode {
        ServiceScheduleMode::Replicated => schedule.replicas,
        ServiceScheduleMode::Daemon => {
            eligible_cluster_nodes(nodes, &schedule.placement, heartbeat_timeout_secs).len() as u32
        }
    }
}

pub fn build_service_status_record(
    service: &ServiceSpec,
    nodes: &[ClusterNodeRecord],
    services_by_name: &HashMap<&str, &ServiceSpec>,
    instances_by_service: &HashMap<String, Vec<ServiceInstanceSpec>>,
    publications: &HashMap<String, ServicePublicationState>,
    dependency_cycles: &HashSet<String>,
    heartbeat_timeout_secs: u64,
) -> ServiceStatusRecord {
    let instances = instances_by_service
        .get(&service.name)
        .map(Vec::as_slice)
        .unwrap_or(&[]);
    let healthy_instances = instances
        .iter()
        .filter(|instance| service_instance_is_available(instance, heartbeat_timeout_secs))
        .count() as u32;
    let scheduled_instances = instances.len() as u32;
    let desired_instances = desired_service_instance_count(service, nodes, heartbeat_timeout_secs);
    let publish_ready = publications
        .get(&service.name)
        .map(service_publication_is_ready)
        .unwrap_or(false);
    let dependencies = summarize_service_dependencies(
        service,
        services_by_name,
        instances_by_service,
        publications,
        dependency_cycles,
        heartbeat_timeout_secs,
    );
    let eligible_node_count = service
        .schedule
        .as_ref()
        .map(|schedule| {
            eligible_cluster_nodes(nodes, &schedule.placement, heartbeat_timeout_secs).len()
        })
        .unwrap_or(0);

    let (phase, message) = if service.schedule.is_none() {
        (
            "unmanaged".to_string(),
            Some("service is not managed by fleet-scheduler".to_string()),
        )
    } else if !dependencies.dependencies_ready {
        (
            "blocked".to_string(),
            Some(dependencies.blockers.join("; ")),
        )
    } else if eligible_node_count == 0 {
        (
            "unschedulable".to_string(),
            Some("no eligible nodes match the service placement policy".to_string()),
        )
    } else if desired_instances == 0 && scheduled_instances == 0 {
        (
            "idle".to_string(),
            Some("service has no desired instances".to_string()),
        )
    } else if desired_instances > 0 && healthy_instances >= desired_instances {
        (
            "healthy".to_string(),
            Some(format!(
                "healthy instances satisfy desired count ({healthy_instances}/{desired_instances})"
            )),
        )
    } else if scheduled_instances > 0 || healthy_instances > 0 {
        (
            "degraded".to_string(),
            Some(format!(
                "healthy={healthy_instances} scheduled={scheduled_instances} desired={desired_instances}"
            )),
        )
    } else {
        (
            "pending".to_string(),
            Some(format!(
                "waiting for instances to reach desired count ({healthy_instances}/{desired_instances})"
            )),
        )
    };

    ServiceStatusRecord {
        service: service.name.clone(),
        phase,
        desired_instances,
        scheduled_instances,
        healthy_instances,
        publish_ready,
        dependencies_ready: dependencies.dependencies_ready,
        blockers: dependencies.blockers,
        message,
        observed_at: Some(Utc::now()),
    }
}

/// mTLS policy definition.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MtlsPolicySpec {
    pub policy_id: String,
    #[serde(default)]
    pub environment: Option<String>,
    pub source_service: String,
    pub target_service: String,
    #[serde(default)]
    pub mtls_required: Option<bool>,
    #[serde(default)]
    pub mode: Option<String>,
}

/// GitOps-friendly cluster declaration.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ClusterStateSpec {
    pub cluster: ClusterSpec,
    #[serde(default)]
    pub nodes: Vec<NodeSpec>,
    #[serde(default)]
    pub node_classes: Vec<NodeClassSpec>,
    #[serde(default)]
    pub pools: Vec<NodePoolSpec>,
    #[serde(default)]
    pub enrollment_rules: Vec<EnrollmentRuleSpec>,
    #[serde(default)]
    pub host_deployments: Vec<HostDeploymentSpec>,
    #[serde(default)]
    pub services: Vec<ServiceSpec>,
    #[serde(default)]
    pub instances: Vec<ServiceInstanceSpec>,
    #[serde(default)]
    pub mtls_policies: Vec<MtlsPolicySpec>,
}

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

    #[test]
    fn test_node_state_default() {
        assert_eq!(NodeState::default(), NodeState::Pending);
    }

    #[test]
    fn test_node_state_serialization() {
        let state = NodeState::Active;
        let json = serde_json::to_string(&state).unwrap();
        assert_eq!(json, r#""active""#);

        let deserialized: NodeState = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized, NodeState::Active);
    }

    #[test]
    fn test_phone_home_request_serialization() {
        let mut metadata = HashMap::new();
        metadata.insert("role".to_string(), "control-plane".to_string());

        let request = PhoneHomeRequest {
            machine_id: "abc123def456".to_string(),
            node_id: Some("node01".to_string()),
            hostname: Some("node01".to_string()),
            ip: Some("10.0.1.10".to_string()),
            cluster_config_hash: Some("abc123".to_string()),
            metadata,
            hardware_facts: Some(HardwareFacts {
                architecture: Some("x86_64".to_string()),
                cpu_model: Some("Example CPU".to_string()),
                cpu_threads: Some(16),
                cpu_cores: Some(8),
                memory_bytes: Some(64 * 1024 * 1024 * 1024),
                disks: vec![DiskFact {
                    name: "nvme0n1".to_string(),
                    path: Some("/dev/nvme0n1".to_string()),
                    by_id: Some("/dev/disk/by-id/nvme-example".to_string()),
                    size_bytes: Some(1_000_000_000_000),
                    model: Some("Example Disk".to_string()),
                    serial: Some("disk-serial".to_string()),
                    rotational: Some(false),
                }],
                nics: vec![NicFact {
                    name: "eno1".to_string(),
                    mac_address: Some("52:54:00:12:34:56".to_string()),
                    oper_state: Some("up".to_string()),
                }],
                dmi: Some(DmiFact {
                    vendor: Some("ExampleVendor".to_string()),
                    product_name: Some("ExampleSystem".to_string()),
                    serial_number: Some("system-serial".to_string()),
                }),
            }),
        };

        let json = serde_json::to_string(&request).unwrap();
        let deserialized: PhoneHomeRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.machine_id, "abc123def456");
        assert_eq!(deserialized.node_id, Some("node01".to_string()));
        assert_eq!(deserialized.metadata.get("role").unwrap(), "control-plane");
        assert_eq!(
            deserialized
                .hardware_facts
                .as_ref()
                .and_then(|facts| facts.disks.first())
                .and_then(|disk| disk.by_id.as_deref()),
            Some("/dev/disk/by-id/nvme-example")
        );
    }

    #[test]
    fn test_phone_home_response_with_secrets() {
        let node_config = NodeConfig::from_parts(
            NodeAssignment {
                node_id: "node01".to_string(),
                hostname: "node01".to_string(),
                role: "control-plane".to_string(),
                ip: "10.0.1.10".to_string(),
                labels: HashMap::new(),
                pool: None,
                node_class: None,
                failure_domain: None,
            },
            BootstrapPlan {
                services: vec!["chainfire".to_string(), "flaredb".to_string()],
                nix_profile: None,
                install_plan: Some(InstallPlan {
                    nixos_configuration: Some("node01".to_string()),
                    disko_config_path: Some("nix/nodes/vm-cluster/node01/disko.nix".to_string()),
                    target_disk: Some("/dev/vda".to_string()),
                    target_disk_by_id: None,
                }),
            },
            BootstrapSecrets {
                ssh_authorized_keys: vec![],
                ssh_host_key: Some("ssh-key-data".to_string()),
                tls_cert: None,
                tls_key: None,
            },
        );

        let response = PhoneHomeResponse {
            success: true,
            message: Some("Node registered".to_string()),
            state: NodeState::Provisioning,
            node_config,
        };

        let json = serde_json::to_string(&response).unwrap();
        let deserialized: PhoneHomeResponse = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.state, NodeState::Provisioning);
        assert_eq!(deserialized.node_config.assignment.node_id, "node01");
        assert_eq!(
            deserialized
                .node_config
                .bootstrap_secrets
                .ssh_host_key
                .as_deref(),
            Some("ssh-key-data")
        );
        let install_plan = deserialized
            .node_config
            .bootstrap_plan
            .install_plan
            .as_ref()
            .expect("install_plan should round-trip");
        assert_eq!(install_plan.nixos_configuration.as_deref(), Some("node01"));
        assert_eq!(install_plan.target_disk.as_deref(), Some("/dev/vda"));
    }

    #[test]
    fn test_node_config_roundtrip() {
        let config = NodeConfig::from_parts(
            NodeAssignment {
                node_id: "node02".to_string(),
                hostname: "node02".to_string(),
                role: "worker".to_string(),
                ip: "10.0.1.12".to_string(),
                labels: HashMap::from([("tier".to_string(), "general".to_string())]),
                pool: Some("general".to_string()),
                node_class: Some("worker-linux".to_string()),
                failure_domain: Some("rack-b".to_string()),
            },
            BootstrapPlan {
                services: vec!["plasmavmc".to_string()],
                nix_profile: Some("profiles/worker-linux".to_string()),
                install_plan: Some(InstallPlan {
                    nixos_configuration: Some("worker-linux".to_string()),
                    disko_config_path: Some("profiles/worker-linux/disko.nix".to_string()),
                    target_disk: None,
                    target_disk_by_id: Some("/dev/disk/by-id/worker-default".to_string()),
                }),
            },
            BootstrapSecrets {
                ssh_authorized_keys: vec!["ssh-ed25519 AAAATEST test".to_string()],
                ssh_host_key: Some("ssh-host-key".to_string()),
                tls_cert: None,
                tls_key: None,
            },
        );

        let json = serde_json::to_string(&config).unwrap();
        let decoded: NodeConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(decoded.assignment.hostname, "node02");
        assert_eq!(decoded.assignment.role, "worker");
        assert_eq!(decoded.assignment.pool.as_deref(), Some("general"));
        assert_eq!(
            decoded.bootstrap_plan.nix_profile.as_deref(),
            Some("profiles/worker-linux")
        );
        assert_eq!(
            decoded
                .bootstrap_plan
                .install_plan
                .as_ref()
                .and_then(|plan| plan.target_disk_by_id.as_deref()),
            Some("/dev/disk/by-id/worker-default")
        );
        assert_eq!(decoded.bootstrap_secrets.ssh_authorized_keys.len(), 1);
    }

    #[test]
    fn test_service_schedule_defaults() {
        let schedule = ServiceScheduleSpec::default();
        assert_eq!(schedule.mode, ServiceScheduleMode::Replicated);
        assert_eq!(schedule.replicas, 1);
        assert_eq!(schedule.placement.max_instances_per_node, 1);
    }

    #[test]
    fn test_service_publication_defaults() {
        let publish = ServicePublicationSpec {
            org_id: Some("default-org".to_string()),
            project_id: Some("default-project".to_string()),
            dns: Some(DnsPublicationSpec {
                zone: "cluster.local".to_string(),
                name: None,
                ttl: default_dns_ttl(),
                mode: DnsPublishMode::default(),
            }),
            load_balancer: None,
        };

        let json = serde_json::to_string(&publish).unwrap();
        let decoded: ServicePublicationSpec = serde_json::from_str(&json).unwrap();
        assert_eq!(decoded.dns.unwrap().mode, DnsPublishMode::LoadBalancer);
    }

    #[test]
    fn test_service_instance_with_process_roundtrip() {
        let instance = ServiceInstanceSpec {
            instance_id: "api-node01".to_string(),
            service: "api".to_string(),
            node_id: "node01".to_string(),
            ip: "10.0.0.10".to_string(),
            port: 8080,
            mesh_port: Some(18080),
            version: Some("v1".to_string()),
            health_check: Some(HealthCheckSpec {
                check_type: "http".to_string(),
                path: Some("/health".to_string()),
                interval_secs: Some(10),
                timeout_secs: Some(5),
                startup_grace_secs: Some(30),
            }),
            process: Some(ProcessSpec {
                command: "/usr/bin/api".to_string(),
                args: vec!["serve".to_string()],
                working_dir: Some("/srv/api".to_string()),
                env: HashMap::from([("RUST_LOG".to_string(), "info".to_string())]),
            }),
            container: Some(ContainerSpec {
                image: "ghcr.io/example/api:latest".to_string(),
                runtime: Some("podman".to_string()),
                command: vec!["/bin/api".to_string()],
                args: vec!["serve".to_string()],
                env: HashMap::new(),
                ports: vec![ContainerPortSpec {
                    container_port: 8080,
                    host_port: Some(8080),
                    protocol: Some("tcp".to_string()),
                }],
                volumes: vec![],
                network_mode: Some("host".to_string()),
                pull_policy: Some("if-not-present".to_string()),
                working_dir: None,
            }),
            managed_by: Some("fleet-scheduler".to_string()),
            state: Some("healthy".to_string()),
            last_heartbeat: None,
            observed_at: None,
        };

        let json = serde_json::to_string(&instance).unwrap();
        let decoded: ServiceInstanceSpec = serde_json::from_str(&json).unwrap();
        assert_eq!(decoded.instance_id, "api-node01");
        assert_eq!(decoded.process.unwrap().command, "/usr/bin/api");
        let health_check = decoded.health_check.unwrap();
        assert_eq!(health_check.check_type, "http");
        assert_eq!(health_check.startup_grace_secs, Some(30));
        assert_eq!(
            decoded.container.unwrap().image,
            "ghcr.io/example/api:latest"
        );
    }

    #[test]
    fn test_service_publication_state_roundtrip() {
        let state = ServicePublicationState {
            service: "api".to_string(),
            org_id: "default-org".to_string(),
            project_id: "default-project".to_string(),
            load_balancer: Some(PublishedLoadBalancerState {
                id: "lb-1".to_string(),
                pool_id: "pool-1".to_string(),
                listener_id: "listener-1".to_string(),
                vip_address: Some("10.0.0.50".to_string()),
            }),
            dns: Some(PublishedDnsRecordState {
                zone_id: "zone-1".to_string(),
                record_ids: vec!["record-1".to_string()],
                fqdn: "api.test.cluster.local".to_string(),
                values: vec!["10.0.0.50".to_string()],
            }),
            observed_at: None,
        };

        let json = serde_json::to_string(&state).unwrap();
        let decoded: ServicePublicationState = serde_json::from_str(&json).unwrap();
        assert_eq!(decoded.service, "api");
        assert_eq!(
            decoded.load_balancer.unwrap().vip_address.as_deref(),
            Some("10.0.0.50")
        );
        assert_eq!(decoded.dns.unwrap().fqdn, "api.test.cluster.local");
    }

    fn active_cluster_node(node_id: &str) -> ClusterNodeRecord {
        ClusterNodeRecord {
            node_id: node_id.to_string(),
            machine_id: None,
            ip: format!("10.0.0.{}", if node_id == "node01" { 11 } else { 12 }),
            hostname: node_id.to_string(),
            roles: vec!["worker".to_string()],
            labels: HashMap::from([("tier".to_string(), "general".to_string())]),
            pool: Some("general".to_string()),
            node_class: Some("worker-linux".to_string()),
            failure_domain: None,
            nix_profile: None,
            install_plan: None,
            hardware_facts: None,
            state: Some("active".to_string()),
            commission_state: None,
            install_state: None,
            commissioned_at: None,
            last_inventory_hash: None,
            power_state: None,
            bmc_ref: None,
            last_heartbeat: Some(Utc::now()),
        }
    }

    fn scheduled_service_named(name: &str) -> ServiceSpec {
        ServiceSpec {
            name: name.to_string(),
            ports: Some(ServicePorts {
                http: Some(8080),
                grpc: None,
            }),
            protocol: None,
            mtls_required: None,
            mesh_mode: None,
            depends_on: Vec::new(),
            schedule: Some(ServiceScheduleSpec {
                mode: ServiceScheduleMode::Replicated,
                replicas: 2,
                placement: PlacementPolicy {
                    roles: vec!["worker".to_string()],
                    pools: Vec::new(),
                    node_classes: Vec::new(),
                    match_labels: HashMap::new(),
                    spread_by_label: None,
                    max_instances_per_node: 1,
                },
                rollout: RolloutStrategySpec::default(),
                instance_port: Some(8080),
                mesh_port: None,
                process: None,
                container: None,
                health_check: None,
            }),
            publish: None,
        }
    }

    fn healthy_service_instance(service: &str, node_id: &str) -> ServiceInstanceSpec {
        ServiceInstanceSpec {
            instance_id: format!("{service}-{node_id}"),
            service: service.to_string(),
            node_id: node_id.to_string(),
            ip: if node_id == "node01" {
                "10.0.0.11".to_string()
            } else {
                "10.0.0.12".to_string()
            },
            port: 8080,
            mesh_port: None,
            version: None,
            health_check: None,
            process: None,
            container: None,
            managed_by: Some("fleet-scheduler".to_string()),
            state: Some("healthy".to_string()),
            last_heartbeat: Some(Utc::now()),
            observed_at: Some(Utc::now()),
        }
    }

    #[test]
    fn test_compute_service_dependency_cycles_detects_full_cycle() {
        let mut api = scheduled_service_named("api");
        api.depends_on = vec![ServiceDependencySpec {
            service: "worker".to_string(),
            condition: ServiceDependencyCondition::Healthy,
            min_ready: 1,
        }];

        let mut worker = scheduled_service_named("worker");
        worker.depends_on = vec![ServiceDependencySpec {
            service: "edge".to_string(),
            condition: ServiceDependencyCondition::Healthy,
            min_ready: 1,
        }];

        let mut edge = scheduled_service_named("edge");
        edge.depends_on = vec![ServiceDependencySpec {
            service: "api".to_string(),
            condition: ServiceDependencyCondition::Healthy,
            min_ready: 1,
        }];

        let cycles = compute_service_dependency_cycles(&[api, worker, edge]);
        assert_eq!(
            cycles,
            HashSet::from(["api".to_string(), "worker".to_string(), "edge".to_string()])
        );
    }

    #[test]
    fn test_summarize_service_dependencies_blocks_until_dependency_is_healthy() {
        let api = scheduled_service_named("api");
        let mut worker = scheduled_service_named("worker");
        worker.depends_on = vec![ServiceDependencySpec {
            service: "api".to_string(),
            condition: ServiceDependencyCondition::Healthy,
            min_ready: 2,
        }];

        let services = vec![api.clone(), worker.clone()];
        let services_by_name = services
            .iter()
            .map(|service| (service.name.as_str(), service))
            .collect::<HashMap<_, _>>();
        let instances_by_service = HashMap::from([(
            "api".to_string(),
            vec![
                healthy_service_instance("api", "node01"),
                ServiceInstanceSpec {
                    state: Some("starting".to_string()),
                    ..healthy_service_instance("api", "node02")
                },
            ],
        )]);

        let summary = summarize_service_dependencies(
            &worker,
            &services_by_name,
            &instances_by_service,
            &HashMap::new(),
            &HashSet::new(),
            300,
        );

        assert!(!summary.dependencies_ready);
        assert_eq!(
            summary.blockers,
            vec!["dependency api has 1/2 healthy instance(s)".to_string()]
        );
    }

    #[test]
    fn test_build_service_status_record_recognizes_published_dependency() {
        let api = scheduled_service_named("api");
        let mut worker = scheduled_service_named("worker");
        worker.depends_on = vec![ServiceDependencySpec {
            service: "api".to_string(),
            condition: ServiceDependencyCondition::Published,
            min_ready: 1,
        }];

        let services = vec![api.clone(), worker.clone()];
        let services_by_name = services
            .iter()
            .map(|service| (service.name.as_str(), service))
            .collect::<HashMap<_, _>>();
        let publications = HashMap::from([(
            "api".to_string(),
            ServicePublicationState {
                service: "api".to_string(),
                org_id: "default-org".to_string(),
                project_id: "default-project".to_string(),
                load_balancer: Some(PublishedLoadBalancerState {
                    id: "lb-1".to_string(),
                    pool_id: "pool-1".to_string(),
                    listener_id: "listener-1".to_string(),
                    vip_address: Some("203.0.113.10".to_string()),
                }),
                dns: None,
                observed_at: Some(Utc::now()),
            },
        )]);
        let nodes = vec![active_cluster_node("node01")];

        let status = build_service_status_record(
            &worker,
            &nodes,
            &services_by_name,
            &HashMap::new(),
            &publications,
            &HashSet::new(),
            300,
        );

        assert_eq!(status.phase, "pending");
        assert!(status.dependencies_ready);
        assert!(status.blockers.is_empty());
    }

    #[test]
    fn test_observed_system_state_roundtrip() {
        let observed = ObservedSystemState {
            node_id: "node01".to_string(),
            nixos_configuration: Some("node01".to_string()),
            flake_root: Some("/opt/ultracloud-src".to_string()),
            target_system: Some("/nix/store/system-node01".to_string()),
            configured_system: Some("/nix/store/system-node01".to_string()),
            current_system: Some("/nix/store/system-old".to_string()),
            booted_system: Some("/nix/store/system-old".to_string()),
            rollback_system: Some("/nix/store/system-old".to_string()),
            switch_action: Some("boot".to_string()),
            reboot_required: Some(true),
            status: Some("pending".to_string()),
            last_attempt: None,
            last_success: None,
            last_error: Some("waiting for apply".to_string()),
        };

        let json = serde_json::to_string(&observed).unwrap();
        let decoded: ObservedSystemState = serde_json::from_str(&json).unwrap();
        assert_eq!(decoded.node_id, "node01");
        assert_eq!(decoded.nixos_configuration.as_deref(), Some("node01"));
        assert_eq!(decoded.status.as_deref(), Some("pending"));
    }

    #[test]
    fn test_desired_system_spec_roundtrip() {
        let desired = DesiredSystemSpec {
            node_id: "node01".to_string(),
            deployment_id: Some("worker-rollout".to_string()),
            nixos_configuration: Some("node01".to_string()),
            target_system: Some("/nix/store/system-node01".to_string()),
            flake_ref: Some("/opt/ultracloud-src".to_string()),
            switch_action: Some("switch".to_string()),
            health_check_command: vec!["systemctl".to_string(), "is-system-running".to_string()],
            rollback_on_failure: Some(true),
            drain_before_apply: Some(true),
        };

        let json = serde_json::to_string(&desired).unwrap();
        let decoded: DesiredSystemSpec = serde_json::from_str(&json).unwrap();
        assert_eq!(decoded.node_id, "node01");
        assert_eq!(decoded.deployment_id.as_deref(), Some("worker-rollout"));
        assert_eq!(decoded.nixos_configuration.as_deref(), Some("node01"));
        assert_eq!(
            decoded.target_system.as_deref(),
            Some("/nix/store/system-node01")
        );
        assert_eq!(decoded.health_check_command.len(), 2);
        assert_eq!(decoded.rollback_on_failure, Some(true));
        assert_eq!(decoded.drain_before_apply, Some(true));
    }

    #[test]
    fn test_host_deployment_roundtrip() {
        let spec = HostDeploymentSpec {
            name: "worker-rollout".to_string(),
            selector: HostDeploymentSelector {
                node_ids: vec![],
                roles: vec!["worker".to_string()],
                pools: vec!["general".to_string()],
                node_classes: vec!["worker-linux".to_string()],
                match_labels: HashMap::from([("tier".to_string(), "general".to_string())]),
            },
            nixos_configuration: Some("worker-golden".to_string()),
            target_system: Some("/nix/store/worker-golden".to_string()),
            flake_ref: Some("/opt/ultracloud-src".to_string()),
            batch_size: Some(1),
            max_unavailable: Some(1),
            health_check_command: vec!["true".to_string()],
            switch_action: Some("boot".to_string()),
            rollback_on_failure: Some(true),
            drain_before_apply: Some(true),
            reboot_policy: Some("always".to_string()),
            paused: Some(false),
        };

        let json = serde_json::to_string(&spec).unwrap();
        let decoded: HostDeploymentSpec = serde_json::from_str(&json).unwrap();
        assert_eq!(decoded.name, "worker-rollout");
        assert_eq!(decoded.batch_size, Some(1));
        assert_eq!(decoded.max_unavailable, Some(1));
        assert_eq!(
            decoded.target_system.as_deref(),
            Some("/nix/store/worker-golden")
        );
        assert_eq!(decoded.selector.roles, vec!["worker".to_string()]);
        assert_eq!(
            decoded
                .selector
                .match_labels
                .get("tier")
                .map(String::as_str),
            Some("general")
        );
        assert_eq!(decoded.drain_before_apply, Some(true));
    }

    #[test]
    fn test_cluster_node_record_commissioning_roundtrip() {
        let node = ClusterNodeRecord {
            node_id: "node01".to_string(),
            machine_id: Some("machine-01".to_string()),
            ip: "10.0.0.11".to_string(),
            hostname: "node01".to_string(),
            roles: vec!["worker".to_string()],
            labels: HashMap::new(),
            pool: Some("general".to_string()),
            node_class: Some("worker-linux".to_string()),
            failure_domain: Some("rack-a".to_string()),
            nix_profile: Some("profiles/worker-linux".to_string()),
            install_plan: None,
            hardware_facts: None,
            state: Some("provisioning".to_string()),
            commission_state: Some(CommissionState::Commissioned),
            install_state: Some(InstallState::Installed),
            commissioned_at: Some(Utc::now()),
            last_inventory_hash: Some("abc123".to_string()),
            power_state: Some(PowerState::On),
            bmc_ref: Some("redfish://lab-rack-a/node01".to_string()),
            last_heartbeat: Some(Utc::now()),
        };

        let json = serde_json::to_string(&node).unwrap();
        let decoded: ClusterNodeRecord = serde_json::from_str(&json).unwrap();
        assert_eq!(
            decoded.commission_state,
            Some(CommissionState::Commissioned)
        );
        assert_eq!(decoded.install_state, Some(InstallState::Installed));
        assert_eq!(decoded.power_state, Some(PowerState::On));
        assert_eq!(
            decoded.bmc_ref.as_deref(),
            Some("redfish://lab-rack-a/node01")
        );
    }

    #[test]
    fn test_install_plan_merges_disk_preferences() {
        let fallback = InstallPlan {
            nixos_configuration: Some("fallback".to_string()),
            disko_config_path: Some("fallback/disko.nix".to_string()),
            target_disk: Some("/dev/sda".to_string()),
            target_disk_by_id: None,
        };
        let preferred = InstallPlan {
            nixos_configuration: None,
            disko_config_path: None,
            target_disk: None,
            target_disk_by_id: Some("/dev/disk/by-id/nvme-example".to_string()),
        };

        let merged = preferred.merged_with(Some(&fallback));
        assert_eq!(merged.nixos_configuration.as_deref(), Some("fallback"));
        assert_eq!(merged.target_disk.as_deref(), Some("/dev/sda"));
        assert_eq!(
            merged.target_disk_by_id.as_deref(),
            Some("/dev/disk/by-id/nvme-example")
        );
    }
}
