use axum::{extract::State, http::HeaderMap, http::StatusCode, Json};
use chrono::Utc;
use deployer_types::{
    BootstrapPlan, BootstrapSecrets, CommissionState, EnrollmentRuleSpec, HardwareFacts,
    InstallPlan, InstallState, NodeAssignment, NodeClassSpec, NodeConfig, NodeInfo, NodePoolSpec,
    NodeState, PhoneHomeRequest, PhoneHomeResponse, PowerState,
};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, error, info, warn};

use crate::auth::require_bootstrap_auth;
use crate::cluster::ClusterNodeRecord;
use crate::state::AppState;
use crate::validation::{validate_identifier, validate_ip};

fn merge_install_plan(
    preferred: Option<&InstallPlan>,
    fallback: Option<&InstallPlan>,
) -> Option<InstallPlan> {
    InstallPlan::from_layers(preferred, fallback)
}

fn merge_hardware_summary_metadata(
    metadata: &mut HashMap<String, String>,
    hardware_facts: Option<&HardwareFacts>,
) {
    let Some(hardware_facts) = hardware_facts else {
        return;
    };

    if let Some(cpu_threads) = hardware_facts.cpu_threads {
        metadata.insert("hardware.cpu_threads".to_string(), cpu_threads.to_string());
    }
    if let Some(cpu_cores) = hardware_facts.cpu_cores {
        metadata.insert("hardware.cpu_cores".to_string(), cpu_cores.to_string());
    }
    if let Some(memory_bytes) = hardware_facts.memory_bytes {
        metadata.insert(
            "hardware.memory_bytes".to_string(),
            memory_bytes.to_string(),
        );
    }
    metadata.insert(
        "hardware.disk_count".to_string(),
        hardware_facts.disks.len().to_string(),
    );
    metadata.insert(
        "hardware.nic_count".to_string(),
        hardware_facts.nics.len().to_string(),
    );
    if let Some(architecture) = hardware_facts.architecture.as_deref() {
        metadata.insert(
            "hardware.architecture".to_string(),
            architecture.to_string(),
        );
    }
}

fn inventory_hash(hardware_facts: Option<&HardwareFacts>) -> Option<String> {
    let hardware_facts = hardware_facts?;
    let payload = serde_json::to_vec(hardware_facts).ok()?;
    let mut hasher = Sha256::new();
    hasher.update(payload);
    Some(format!("{:x}", hasher.finalize()))
}

/// POST /api/v1/phone-home
pub async fn phone_home(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(request): Json<PhoneHomeRequest>,
) -> Result<Json<PhoneHomeResponse>, (StatusCode, String)> {
    require_bootstrap_auth(&state, &headers)?;
    validate_identifier(&request.machine_id, "machine_id")?;
    if let Some(node_id) = request.node_id.as_ref() {
        validate_identifier(node_id, "node_id")?;
    }
    if let Some(ip) = request.ip.as_ref() {
        validate_ip(ip, "ip")?;
    }

    info!(
        machine_id = %request.machine_id,
        "Phone home request received"
    );

    let mut node_config = match lookup_node_config(&state, &request.machine_id).await {
        Some(config) => config,
        None => match resolve_enrollment_config(&state, &request).await? {
            Some(config) => {
                info!(
                    machine_id = %request.machine_id,
                    node_id = %config.assignment.node_id,
                    "Resolved unknown machine through enrollment rules"
                );
                config
            }
            None => {
                if !state.config.allow_unknown_nodes {
                    warn!(
                        machine_id = %request.machine_id,
                        "Unknown machine-id rejected (pre-registration required)"
                    );
                    return Err((
                        StatusCode::FORBIDDEN,
                        "machine-id not registered".to_string(),
                    ));
                }

                warn!(
                    machine_id = %request.machine_id,
                    "Unknown machine-id, assigning default configuration (unsafe)"
                );
                default_unknown_node_config(&request)
            }
        },
    };

    if let Some(requested_id) = request.node_id.as_ref() {
        if requested_id != &node_config.assignment.node_id {
            warn!(
                machine_id = %request.machine_id,
                requested_id = %requested_id,
                expected_id = %node_config.assignment.node_id,
                "Node ID mismatch in phone-home"
            );
            return Err((StatusCode::BAD_REQUEST, "node_id mismatch".to_string()));
        }
    }

    if node_config.assignment.hostname.is_empty() {
        node_config.assignment.hostname = request
            .hostname
            .clone()
            .filter(|value| !value.trim().is_empty())
            .unwrap_or_else(|| node_config.assignment.node_id.clone());
    }

    if let Some(request_ip) = request.ip.as_ref() {
        if !node_config.assignment.ip.is_empty() && node_config.assignment.ip != *request_ip {
            warn!(
                machine_id = %request.machine_id,
                requested_ip = %request_ip,
                expected_ip = %node_config.assignment.ip,
                "Node IP mismatch in phone-home"
            );
            return Err((StatusCode::BAD_REQUEST, "node ip mismatch".to_string()));
        }
    }

    if node_config.assignment.ip.is_empty() {
        if let Some(ip) = request.ip.clone() {
            node_config.assignment.ip = ip;
        } else {
            warn!(
                machine_id = %request.machine_id,
                node_id = %node_config.assignment.node_id,
                "Node config missing IP; refusing registration"
            );
            return Err((StatusCode::BAD_REQUEST, "node ip missing".to_string()));
        }
    }

    validate_ip(&node_config.assignment.ip, "node_config.assignment.ip")?;

    let mut metadata = request.metadata.clone();
    metadata.insert("role".to_string(), node_config.assignment.role.clone());
    metadata.insert(
        "services".to_string(),
        node_config.bootstrap_plan.services.join(","),
    );
    merge_hardware_summary_metadata(&mut metadata, request.hardware_facts.as_ref());

    let node_info = NodeInfo {
        id: node_config.assignment.node_id.clone(),
        machine_id: Some(request.machine_id.clone()),
        hostname: node_config.assignment.hostname.clone(),
        ip: node_config.assignment.ip.clone(),
        state: NodeState::Provisioning,
        cluster_config_hash: request.cluster_config_hash.unwrap_or_default(),
        last_heartbeat: Utc::now(),
        metadata,
    };

    let mut response_config = node_config.clone();
    response_config.bootstrap_secrets.ssh_host_key =
        get_or_issue_ssh_host_key(&state, &node_info.id).await;

    let (tls_cert, tls_key) =
        get_or_issue_tls_material(&state, &node_info.id, &node_info.hostname, &node_info.ip).await;
    response_config.bootstrap_secrets.tls_cert = tls_cert;
    response_config.bootstrap_secrets.tls_key = tls_key;

    if let Err(e) = persist_node_config(&state, &request.machine_id, &response_config).await {
        warn!(
            machine_id = %request.machine_id,
            node_id = %node_info.id,
            error = %e,
            "Failed to persist node configuration"
        );
    }

    match store_node_info(&state, &node_info).await {
        Ok(_) => {
            let storage = if state.has_local_storage() {
                "local"
            } else if state.has_storage() {
                "chainfire"
            } else {
                "in-memory"
            };
            info!(
                node_id = %node_info.id,
                hostname = %node_info.hostname,
                role = %response_config.assignment.role,
                storage = storage,
                "Node registered successfully"
            );

            if let Err(e) = store_cluster_node_if_configured(
                &state,
                &node_info,
                &response_config,
                &request.machine_id,
                request.hardware_facts.as_ref(),
            )
            .await
            {
                warn!(
                    node_id = %node_info.id,
                    error = %e,
                    "Failed to store cluster node state"
                );
            }

            Ok(Json(PhoneHomeResponse {
                success: true,
                message: Some(format!("Node {} registered successfully", node_info.id)),
                state: NodeState::Provisioning,
                node_config: response_config,
            }))
        }
        Err(e) => {
            error!(
                machine_id = %request.machine_id,
                error = %e,
                "Failed to store node info"
            );

            Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Failed to register node: {}", e),
            ))
        }
    }
}

fn default_unknown_node_config(request: &PhoneHomeRequest) -> NodeConfig {
    let node_id = request
        .node_id
        .as_ref()
        .map(|v| v.trim())
        .filter(|v| !v.is_empty())
        .map(|v| v.to_string())
        .or_else(|| {
            request
                .hostname
                .as_ref()
                .map(|v| v.trim())
                .filter(|v| !v.is_empty())
                .map(|v| v.to_string())
        })
        .unwrap_or_else(|| {
            let max_suffix_len = 128usize.saturating_sub("node-".len());
            let suffix_len = std::cmp::min(max_suffix_len, request.machine_id.len());
            format!("node-{}", &request.machine_id[..suffix_len])
        });

    NodeConfig::from_parts(
        NodeAssignment {
            node_id: node_id.clone(),
            hostname: request
                .hostname
                .clone()
                .filter(|value| !value.trim().is_empty())
                .unwrap_or_else(|| node_id.clone()),
            role: "worker".to_string(),
            ip: request.ip.clone().unwrap_or_default(),
            labels: HashMap::new(),
            pool: None,
            node_class: None,
            failure_domain: request.metadata.get("failure_domain").cloned(),
        },
        BootstrapPlan::default(),
        BootstrapSecrets::default(),
    )
}

async fn get_or_issue_ssh_host_key(state: &AppState, node_id: &str) -> Option<String> {
    let Some(local_storage) = &state.local_storage else {
        return None;
    };

    let mut storage = local_storage.lock().await;
    match storage.get_or_generate_ssh_host_key(node_id) {
        Ok(key) => Some(key),
        Err(e) => {
            warn!(error = %e, "Failed to generate ssh host key");
            None
        }
    }
}

async fn get_or_issue_tls_material(
    state: &AppState,
    node_id: &str,
    hostname: &str,
    ip: &str,
) -> (Option<String>, Option<String>) {
    if !(state.config.tls_self_signed
        || (state.config.tls_ca_cert_path.is_some() && state.config.tls_ca_key_path.is_some()))
    {
        return (None, None);
    }

    if let Some(local_storage) = &state.local_storage {
        let mut storage = local_storage.lock().await;
        match storage.get_or_generate_tls_cert(
            node_id,
            hostname,
            ip,
            state.config.tls_ca_cert_path.as_deref(),
            state.config.tls_ca_key_path.as_deref(),
        ) {
            Ok((cert, key)) => (Some(cert), Some(key)),
            Err(e) => {
                warn!(error = %e, "Failed to issue node TLS certificate");
                (None, None)
            }
        }
    } else {
        match crate::tls::issue_node_cert(
            node_id,
            hostname,
            ip,
            state.config.tls_ca_cert_path.as_deref(),
            state.config.tls_ca_key_path.as_deref(),
        ) {
            Ok((cert, key)) => (Some(cert), Some(key)),
            Err(e) => {
                warn!(error = %e, "Failed to issue node TLS certificate");
                (None, None)
            }
        }
    }
}

/// Lookup node configuration by machine-id.
pub(crate) async fn lookup_node_config(state: &AppState, machine_id: &str) -> Option<NodeConfig> {
    debug!(machine_id = %machine_id, "Looking up node configuration");

    if let Some(local_storage) = &state.local_storage {
        let storage = local_storage.lock().await;
        if let Some(config) = storage.get_node_config(machine_id) {
            debug!(
                machine_id = %machine_id,
                node_id = %config.assignment.node_id,
                "Found config in local storage"
            );
            return Some(config);
        }
    }

    if let Some(storage_mutex) = &state.storage {
        let mut storage = storage_mutex.lock().await;
        match storage.get_node_config(machine_id).await {
            Ok(Some(config)) => {
                debug!(
                    machine_id = %machine_id,
                    node_id = %config.assignment.node_id,
                    "Found config in ChainFire"
                );
                return Some(config);
            }
            Ok(None) => {
                debug!(machine_id = %machine_id, "Not found in ChainFire");
            }
            Err(e) => {
                warn!(
                    machine_id = %machine_id,
                    error = %e,
                    "ChainFire lookup failed, trying fallback"
                );
            }
        }
    }

    let configs = state.machine_configs.read().await;
    if let Some(config) = configs.get(machine_id) {
        debug!(
            machine_id = %machine_id,
            node_id = %config.assignment.node_id,
            "Found config in in-memory storage"
        );
        return Some(config.clone());
    }

    if state.config.allow_test_mappings {
        match machine_id {
            "test-machine-01" => {
                return Some(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: None,
                    },
                    BootstrapSecrets::default(),
                ));
            }
            "test-machine-02" => {
                return Some(NodeConfig::from_parts(
                    NodeAssignment {
                        node_id: "node02".to_string(),
                        hostname: "node02".to_string(),
                        role: "worker".to_string(),
                        ip: "10.0.1.11".to_string(),
                        labels: HashMap::new(),
                        pool: None,
                        node_class: None,
                        failure_domain: None,
                    },
                    BootstrapPlan {
                        services: vec!["chainfire".to_string()],
                        nix_profile: None,
                        install_plan: None,
                    },
                    BootstrapSecrets::default(),
                ));
            }
            _ => {}
        }
    }

    None
}

async fn resolve_enrollment_config(
    state: &AppState,
    request: &PhoneHomeRequest,
) -> Result<Option<NodeConfig>, (StatusCode, String)> {
    let Some(cluster_id) = state.config.cluster_id.as_deref() else {
        return Ok(None);
    };
    let Some(storage_mutex) = &state.storage else {
        return Ok(None);
    };

    let cluster_namespace = state.config.cluster_namespace.trim();
    if cluster_namespace.is_empty() {
        return Ok(None);
    }

    let mut storage = storage_mutex.lock().await;
    let mut rules = storage
        .list_enrollment_rules(cluster_namespace, cluster_id)
        .await
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("failed to load enrollment rules: {}", e),
            )
        })?;
    if rules.is_empty() {
        return Ok(None);
    }

    let node_classes = storage
        .list_node_classes(cluster_namespace, cluster_id)
        .await
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("failed to load node classes: {}", e),
            )
        })?;
    let pools = storage
        .list_pools(cluster_namespace, cluster_id)
        .await
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("failed to load pools: {}", e),
            )
        })?;
    drop(storage);

    rules.sort_by(|lhs, rhs| {
        rhs.priority
            .cmp(&lhs.priority)
            .then_with(|| lhs.name.cmp(&rhs.name))
    });

    let Some(rule) = rules
        .iter()
        .find(|rule| enrollment_rule_matches(rule, request))
    else {
        return Ok(None);
    };

    Ok(Some(build_node_config_from_rule(
        rule,
        request,
        &node_classes,
        &pools,
    )))
}

fn enrollment_rule_matches(rule: &EnrollmentRuleSpec, request: &PhoneHomeRequest) -> bool {
    if let Some(prefix) = rule.match_hostname_prefix.as_deref() {
        let Some(hostname) = request.hostname.as_deref() else {
            return false;
        };
        if !hostname.starts_with(prefix) {
            return false;
        }
    }

    if !rule.match_ip_prefixes.is_empty() {
        let Some(ip) = request.ip.as_deref() else {
            return false;
        };
        if !rule
            .match_ip_prefixes
            .iter()
            .any(|prefix| ip.starts_with(prefix))
        {
            return false;
        }
    }

    rule.match_labels
        .iter()
        .all(|(key, value)| request.metadata.get(key) == Some(value))
}

fn build_node_config_from_rule(
    rule: &EnrollmentRuleSpec,
    request: &PhoneHomeRequest,
    node_classes: &[NodeClassSpec],
    pools: &[NodePoolSpec],
) -> NodeConfig {
    let requested_id = request
        .node_id
        .as_ref()
        .map(|value| value.trim())
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .or_else(|| {
            request
                .hostname
                .as_ref()
                .map(|value| value.trim())
                .filter(|value| !value.is_empty())
                .map(str::to_string)
        });

    let node_id = requested_id.unwrap_or_else(|| {
        let prefix = rule.node_id_prefix.as_deref().unwrap_or("node");
        let suffix_len = std::cmp::min(12usize, request.machine_id.len());
        format!("{prefix}-{}", &request.machine_id[..suffix_len])
    });

    let pool = rule.pool.clone();
    let pool_spec = pool
        .as_deref()
        .and_then(|name| pools.iter().find(|pool| pool.name == name));
    let node_class = rule
        .node_class
        .clone()
        .or_else(|| pool_spec.and_then(|pool| pool.node_class.clone()));
    let node_class_spec = node_class.as_deref().and_then(|name| {
        node_classes
            .iter()
            .find(|node_class| node_class.name == name)
    });

    let role = rule
        .role
        .clone()
        .or_else(|| node_class_spec.and_then(|node_class| node_class.roles.first().cloned()))
        .unwrap_or_else(|| "worker".to_string());

    let mut labels = HashMap::new();
    if let Some(node_class) = node_class_spec {
        labels.extend(node_class.labels.clone());
    }
    if let Some(pool) = pool_spec {
        for (key, value) in &pool.labels {
            labels.entry(key.clone()).or_insert_with(|| value.clone());
        }
    }
    for (key, value) in &rule.labels {
        labels.insert(key.clone(), value.clone());
    }
    if let Some(pool_name) = pool.as_deref() {
        labels
            .entry("pool".to_string())
            .or_insert_with(|| pool_name.to_string());
    }
    if let Some(node_class_name) = node_class.as_deref() {
        labels
            .entry("node_class".to_string())
            .or_insert_with(|| node_class_name.to_string());
    }

    let failure_domain = request
        .metadata
        .get("failure_domain")
        .cloned()
        .or_else(|| request.metadata.get("topology.kubernetes.io/zone").cloned());

    NodeConfig::from_parts(
        NodeAssignment {
            node_id: node_id.clone(),
            hostname: request
                .hostname
                .clone()
                .filter(|value| !value.trim().is_empty())
                .unwrap_or_else(|| node_id.clone()),
            role,
            ip: request.ip.clone().unwrap_or_default(),
            labels,
            pool,
            node_class,
            failure_domain,
        },
        BootstrapPlan {
            services: rule.services.clone(),
            nix_profile: rule
                .nix_profile
                .clone()
                .or_else(|| node_class_spec.and_then(|node_class| node_class.nix_profile.clone())),
            install_plan: merge_install_plan(
                rule.install_plan.as_ref(),
                node_class_spec.and_then(|node_class| node_class.install_plan.as_ref()),
            ),
        },
        BootstrapSecrets {
            ssh_authorized_keys: rule.ssh_authorized_keys.clone(),
            ssh_host_key: None,
            tls_cert: None,
            tls_key: None,
        },
    )
}

async fn store_node_info(state: &AppState, node_info: &NodeInfo) -> anyhow::Result<()> {
    let mut stored = false;

    if let Some(local_storage) = &state.local_storage {
        let mut storage = local_storage.lock().await;
        match storage.store_node_info(node_info) {
            Ok(()) => {
                stored = true;
                debug!(node_id = %node_info.id, "Stored node info in local storage");
            }
            Err(e) => {
                warn!(error = %e, "Failed to store node info in local storage");
            }
        }
    }

    if let Some(storage_mutex) = &state.storage {
        let mut chainfire = storage_mutex.lock().await;
        match chainfire.store_node_info(node_info).await {
            Ok(()) => {
                stored = true;
                debug!(node_id = %node_info.id, "Stored node info in ChainFire");
            }
            Err(e) => {
                warn!(error = %e, "Failed to store node info in ChainFire");
            }
        }
    }

    if stored {
        return Ok(());
    }

    state
        .nodes
        .write()
        .await
        .insert(node_info.id.clone(), node_info.clone());

    debug!(
        node_id = %node_info.id,
        "Stored node info in-memory (all backends unavailable)"
    );

    Ok(())
}

async fn persist_node_config(
    state: &AppState,
    machine_id: &str,
    config: &NodeConfig,
) -> anyhow::Result<()> {
    if let Some(local_storage) = &state.local_storage {
        let mut storage = local_storage.lock().await;
        if let Err(e) = storage.register_node(machine_id, config) {
            warn!(
                machine_id = %machine_id,
                node_id = %config.assignment.node_id,
                error = %e,
                "Failed to persist node config to local storage"
            );
        }
    }

    if let Some(storage_mutex) = &state.storage {
        let mut storage = storage_mutex.lock().await;
        if let Err(e) = storage.register_node(machine_id, config).await {
            warn!(
                machine_id = %machine_id,
                node_id = %config.assignment.node_id,
                error = %e,
                "Failed to persist node config to ChainFire"
            );
        }
    }

    let mut map = state.machine_configs.write().await;
    if let Some(existing) = map.get(machine_id) {
        if existing.assignment.node_id != config.assignment.node_id {
            warn!(
                machine_id = %machine_id,
                existing_node = %existing.assignment.node_id,
                requested_node = %config.assignment.node_id,
                "Skipping in-memory mapping update due to conflict"
            );
            return Ok(());
        }
    }
    map.insert(machine_id.to_string(), config.clone());

    Ok(())
}

async fn store_cluster_node_if_configured(
    state: &AppState,
    node_info: &NodeInfo,
    node_config: &NodeConfig,
    machine_id: &str,
    hardware_facts: Option<&HardwareFacts>,
) -> anyhow::Result<()> {
    let Some(cluster_id) = state.config.cluster_id.as_deref() else {
        debug!("cluster_id not configured; skipping cluster node state write");
        return Ok(());
    };

    if cluster_id.trim().is_empty() {
        debug!("cluster_id is empty; skipping cluster node state write");
        return Ok(());
    }

    let cluster_namespace = state.config.cluster_namespace.trim();
    if cluster_namespace.is_empty() {
        debug!("cluster_namespace is empty; skipping cluster node state write");
        return Ok(());
    }

    let mut labels = node_config.assignment.labels.clone();
    for (key, value) in &node_info.metadata {
        labels.insert(key.clone(), value.clone());
    }
    labels.remove("role");
    labels.remove("services");

    let mut roles = Vec::new();
    if !node_config.assignment.role.trim().is_empty() {
        roles.push(node_config.assignment.role.clone());
    } else if let Some(role) = node_info.metadata.get("role") {
        if !role.trim().is_empty() {
            roles.push(role.clone());
        }
    }

    let record = ClusterNodeRecord {
        node_id: node_info.id.clone(),
        machine_id: Some(machine_id.to_string()),
        ip: node_info.ip.clone(),
        hostname: node_info.hostname.clone(),
        roles,
        labels,
        pool: node_config.assignment.pool.clone(),
        node_class: node_config.assignment.node_class.clone(),
        failure_domain: node_config.assignment.failure_domain.clone(),
        nix_profile: node_config.bootstrap_plan.nix_profile.clone(),
        install_plan: node_config.bootstrap_plan.install_plan.clone(),
        hardware_facts: hardware_facts.cloned(),
        state: Some(format!("{:?}", node_info.state).to_lowercase()),
        commission_state: hardware_facts.map(|_| CommissionState::Discovered),
        install_state: node_config
            .bootstrap_plan
            .install_plan
            .as_ref()
            .map(|_| InstallState::Pending),
        commissioned_at: None,
        last_inventory_hash: inventory_hash(hardware_facts),
        power_state: node_info
            .metadata
            .get("power_state")
            .and_then(|value| match value.as_str() {
                "on" => Some(PowerState::On),
                "off" => Some(PowerState::Off),
                "cycling" => Some(PowerState::Cycling),
                "unknown" => Some(PowerState::Unknown),
                _ => None,
            }),
        bmc_ref: node_info.metadata.get("bmc_ref").cloned(),
        last_heartbeat: Some(node_info.last_heartbeat),
    };

    if let Some(local_storage) = &state.local_storage {
        let mut storage = local_storage.lock().await;
        if let Err(e) =
            storage.store_cluster_node(cluster_namespace, cluster_id, &node_info.id, &record)
        {
            warn!(error = %e, "Failed to store cluster node in local storage");
        }
    }

    if let Some(storage_mutex) = &state.storage {
        let mut storage = storage_mutex.lock().await;
        if let Err(e) = storage
            .store_cluster_node(cluster_namespace, cluster_id, &node_info.id, &record)
            .await
        {
            warn!(error = %e, "Failed to store cluster node in ChainFire");
        }
    } else if state.local_storage.is_none() {
        debug!("ChainFire storage unavailable; skipping cluster node state write");
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Config;
    use crate::state::AppState;
    use axum::http::HeaderMap;

    fn test_headers() -> HeaderMap {
        let mut headers = HeaderMap::new();
        headers.insert("x-deployer-token", "test-token".parse().unwrap());
        headers
    }

    fn test_state() -> Arc<AppState> {
        let mut config = Config::default();
        config.bootstrap_token = Some("test-token".to_string());
        Arc::new(AppState::with_config(config))
    }

    fn test_node_config(node_id: &str, role: &str, ip: &str, services: Vec<&str>) -> NodeConfig {
        NodeConfig::from_parts(
            NodeAssignment {
                node_id: node_id.to_string(),
                hostname: node_id.to_string(),
                role: role.to_string(),
                ip: ip.to_string(),
                labels: HashMap::new(),
                pool: None,
                node_class: None,
                failure_domain: None,
            },
            BootstrapPlan {
                services: services.into_iter().map(str::to_string).collect(),
                nix_profile: None,
                install_plan: None,
            },
            BootstrapSecrets::default(),
        )
    }

    #[tokio::test]
    async fn test_phone_home_known_machine() {
        let state = test_state();

        state.machine_configs.write().await.insert(
            "test-machine-01".to_string(),
            test_node_config(
                "node01",
                "control-plane",
                "10.0.1.10",
                vec!["chainfire", "flaredb"],
            ),
        );

        let request = PhoneHomeRequest {
            machine_id: "test-machine-01".to_string(),
            node_id: None,
            hostname: None,
            ip: None,
            cluster_config_hash: None,
            metadata: HashMap::new(),
            hardware_facts: None,
        };

        let result = phone_home(State(state.clone()), test_headers(), Json(request)).await;
        assert!(result.is_ok());

        let response = result.unwrap().0;
        assert!(response.success);
        assert_eq!(response.state, NodeState::Provisioning);
        assert_eq!(response.node_config.assignment.node_id, "node01");
        assert_eq!(response.node_config.assignment.role, "control-plane");
        assert_eq!(
            response.node_config.bootstrap_plan.services,
            vec!["chainfire".to_string(), "flaredb".to_string()]
        );

        let nodes = state.nodes.read().await;
        assert!(nodes.contains_key("node01"));
    }

    #[tokio::test]
    async fn test_phone_home_unknown_machine() {
        let mut config = Config::default();
        config.bootstrap_token = Some("test-token".to_string());
        config.allow_unknown_nodes = true;
        let state = Arc::new(AppState::with_config(config));

        let request = PhoneHomeRequest {
            machine_id: "unknown-machine-xyz".to_string(),
            node_id: None,
            hostname: None,
            ip: Some("10.0.1.100".to_string()),
            cluster_config_hash: None,
            metadata: HashMap::new(),
            hardware_facts: None,
        };

        let result = phone_home(State(state.clone()), test_headers(), Json(request)).await;
        assert!(result.is_ok());

        let response = result.unwrap().0;
        assert!(response.success);
        assert_eq!(response.state, NodeState::Provisioning);
        assert!(response.node_config.assignment.node_id.starts_with("node-"));
        assert_eq!(response.node_config.assignment.role, "worker");
    }

    #[tokio::test]
    async fn test_phone_home_with_preregistered_config() {
        let state = test_state();

        state.machine_configs.write().await.insert(
            "preregistered-123".to_string(),
            test_node_config("my-node", "storage", "10.0.2.50", vec!["lightningstor"]),
        );

        let request = PhoneHomeRequest {
            machine_id: "preregistered-123".to_string(),
            node_id: None,
            hostname: None,
            ip: None,
            cluster_config_hash: None,
            metadata: HashMap::new(),
            hardware_facts: None,
        };

        let result = phone_home(State(state.clone()), test_headers(), Json(request)).await;
        assert!(result.is_ok());

        let response = result.unwrap().0;
        assert!(response.success);
        assert_eq!(response.node_config.assignment.node_id, "my-node");
        assert_eq!(response.node_config.assignment.role, "storage");
        assert_eq!(response.node_config.assignment.ip, "10.0.2.50");
    }

    #[test]
    fn test_enrollment_rule_matching() {
        let rule = EnrollmentRuleSpec {
            name: "gpu".to_string(),
            priority: 10,
            match_labels: HashMap::from([("sku".to_string(), "gpu".to_string())]),
            match_hostname_prefix: Some("gpu-".to_string()),
            match_ip_prefixes: vec!["10.0.3.".to_string()],
            pool: Some("gpu".to_string()),
            node_class: Some("gpu-worker".to_string()),
            role: None,
            labels: HashMap::new(),
            nix_profile: None,
            install_plan: None,
            services: vec![],
            ssh_authorized_keys: vec![],
            node_id_prefix: Some("gpu".to_string()),
        };

        let request = PhoneHomeRequest {
            machine_id: "machine-1".to_string(),
            node_id: None,
            hostname: Some("gpu-node-01".to_string()),
            ip: Some("10.0.3.25".to_string()),
            cluster_config_hash: None,
            metadata: HashMap::from([("sku".to_string(), "gpu".to_string())]),
            hardware_facts: None,
        };

        assert!(enrollment_rule_matches(&rule, &request));
    }

    #[test]
    fn test_build_node_config_from_rule_inherits_class_and_pool() {
        let rule = EnrollmentRuleSpec {
            name: "gpu".to_string(),
            priority: 10,
            match_labels: HashMap::new(),
            match_hostname_prefix: None,
            match_ip_prefixes: vec![],
            pool: Some("gpu".to_string()),
            node_class: None,
            role: None,
            labels: HashMap::from([("accelerator".to_string(), "nvidia".to_string())]),
            nix_profile: None,
            install_plan: None,
            services: vec!["gpu-agent".to_string()],
            ssh_authorized_keys: vec!["ssh-ed25519 test".to_string()],
            node_id_prefix: Some("gpu".to_string()),
        };
        let request = PhoneHomeRequest {
            machine_id: "abcdef123456".to_string(),
            node_id: None,
            hostname: Some("gpu-dyn-01".to_string()),
            ip: Some("10.0.9.10".to_string()),
            cluster_config_hash: None,
            metadata: HashMap::from([(
                "topology.kubernetes.io/zone".to_string(),
                "rack-z".to_string(),
            )]),
            hardware_facts: None,
        };
        let node_classes = vec![NodeClassSpec {
            name: "gpu-worker".to_string(),
            description: None,
            nix_profile: Some("profiles/gpu-worker".to_string()),
            install_plan: Some(InstallPlan {
                nixos_configuration: Some("gpu-worker".to_string()),
                disko_config_path: Some("profiles/gpu-worker/disko.nix".to_string()),
                target_disk: Some("/dev/disk/by-id/nvme-gpu-worker".to_string()),
                target_disk_by_id: None,
            }),
            roles: vec!["worker".to_string()],
            labels: HashMap::from([("tier".to_string(), "gpu".to_string())]),
        }];
        let pools = vec![NodePoolSpec {
            name: "gpu".to_string(),
            description: None,
            node_class: Some("gpu-worker".to_string()),
            min_size: None,
            max_size: None,
            labels: HashMap::from([("pool-kind".to_string(), "accelerated".to_string())]),
        }];

        let config = build_node_config_from_rule(&rule, &request, &node_classes, &pools);

        assert_eq!(config.assignment.node_id, "gpu-dyn-01");
        assert_eq!(config.assignment.role, "worker");
        assert_eq!(config.assignment.pool.as_deref(), Some("gpu"));
        assert_eq!(config.assignment.node_class.as_deref(), Some("gpu-worker"));
        assert_eq!(
            config.bootstrap_plan.nix_profile.as_deref(),
            Some("profiles/gpu-worker")
        );
        let install_plan = config
            .bootstrap_plan
            .install_plan
            .expect("install_plan should inherit from class");
        assert_eq!(
            install_plan.nixos_configuration.as_deref(),
            Some("gpu-worker")
        );
        assert_eq!(
            install_plan.disko_config_path.as_deref(),
            Some("profiles/gpu-worker/disko.nix")
        );
        assert_eq!(
            config.assignment.labels.get("tier").map(String::as_str),
            Some("gpu")
        );
        assert_eq!(
            config
                .assignment
                .labels
                .get("pool-kind")
                .map(String::as_str),
            Some("accelerated")
        );
        assert_eq!(
            config
                .assignment
                .labels
                .get("accelerator")
                .map(String::as_str),
            Some("nvidia")
        );
        assert_eq!(config.assignment.failure_domain.as_deref(), Some("rack-z"));
        assert_eq!(
            config.bootstrap_secrets.ssh_authorized_keys,
            vec!["ssh-ed25519 test".to_string()]
        );
    }
}
