//! End-to-end tests for flexible hierarchy system with multiple nodes
//!
//! These tests validate the hierarchy strategy system in multi-node scenarios,
//! including dynamic election, role transitions, and lateral peer coordination.

use peat_mesh::beacon::{
    GeoPosition, GeographicBeacon, HierarchyLevel, NodeMobility, NodeProfile, NodeResources,
};
use peat_mesh::hierarchy::{
    DynamicHierarchyStrategy, ElectionConfig, HierarchyStrategy, HybridHierarchyStrategy, NodeRole,
    StaticHierarchyStrategy,
};
use std::sync::Arc;

/// Helper to create a test node profile
fn create_node_profile(
    mobility: NodeMobility,
    cpu_cores: u8,
    memory_mb: u32,
    cpu_usage: u8,
    battery_percent: Option<u8>,
) -> NodeProfile {
    NodeProfile {
        mobility,
        resources: NodeResources {
            cpu_cores,
            memory_mb,
            bandwidth_mbps: 100,
            cpu_usage_percent: cpu_usage,
            memory_usage_percent: 50,
            battery_percent,
        },
        can_parent: true,
        prefer_leaf: false,
        parent_priority: 100,
    }
}

/// Helper to create a test beacon
fn create_beacon(
    node_id: &str,
    position: GeoPosition,
    level: HierarchyLevel,
    profile: NodeProfile,
) -> GeographicBeacon {
    let mut beacon = GeographicBeacon::new(node_id.to_string(), position, level);
    beacon.mobility = Some(profile.mobility);
    beacon.resources = Some(profile.resources.clone());
    beacon.can_parent = profile.can_parent;
    beacon.parent_priority = profile.parent_priority;
    beacon
}

#[tokio::test]
async fn test_dynamic_hierarchy_election_with_three_nodes() {
    // Test Scenario: 3 nodes at Cell level with different capabilities
    // Node1: High capability (Static, 8 cores, low usage) -> Should become Leader
    // Node2: Medium capability (SemiMobile, 4 cores) -> Should become Member
    // Node3: Low capability (Mobile, 2 cores, high usage) -> Should become Member

    let node1_profile = create_node_profile(NodeMobility::Static, 8, 4096, 20, None);
    let node2_profile = create_node_profile(NodeMobility::SemiMobile, 4, 2048, 40, Some(80));
    let node3_profile = create_node_profile(NodeMobility::Mobile, 2, 1024, 70, Some(30));

    // Create beacons for nearby peers (from node1's perspective)
    let node2_beacon = create_beacon(
        "node2",
        GeoPosition::new(37.7750, -122.4195), // San Francisco
        HierarchyLevel::Cell,
        node2_profile.clone(),
    );

    let node3_beacon = create_beacon(
        "node3",
        GeoPosition::new(37.7760, -122.4185), // Very close to node1
        HierarchyLevel::Cell,
        node3_profile.clone(),
    );

    // Node1 uses dynamic strategy
    let strategy = Arc::new(DynamicHierarchyStrategy::new(
        HierarchyLevel::Cell,
        ElectionConfig::default(),
        false, // allow_level_transitions = false
    ));

    // Node1 determines its role based on seeing node2 and node3
    let nearby_beacons = vec![node2_beacon.clone(), node3_beacon.clone()];
    let node1_role = strategy.determine_role(&node1_profile, &nearby_beacons);
    let node1_level = strategy.determine_level(&node1_profile);

    // Verify node1 becomes Leader (highest capability)
    assert_eq!(
        node1_role,
        NodeRole::Leader,
        "Node1 with highest capability should be Leader"
    );
    assert_eq!(node1_level, HierarchyLevel::Cell);

    // Node2 perspective: Sees node1 (higher capability) and node3 (lower capability)
    let node1_beacon = create_beacon(
        "node1",
        GeoPosition::new(37.7755, -122.4190),
        HierarchyLevel::Cell,
        node1_profile.clone(),
    );

    let node2_nearby = vec![node1_beacon.clone(), node3_beacon.clone()];
    let node2_role = strategy.determine_role(&node2_profile, &node2_nearby);

    // Verify node2 becomes Member (node1 has higher capability)
    assert_eq!(
        node2_role,
        NodeRole::Member,
        "Node2 should be Member when higher-capability leader exists"
    );

    // Node3 perspective: Sees node1 and node2 (both higher capability)
    let node3_nearby = vec![node1_beacon, node2_beacon];
    let node3_role = strategy.determine_role(&node3_profile, &node3_nearby);

    // Verify node3 becomes Member
    assert_eq!(
        node3_role,
        NodeRole::Member,
        "Node3 with lowest capability should be Member"
    );
}

#[tokio::test]
async fn test_role_transition_when_leader_leaves() {
    // Test Scenario: Leader node leaves, next highest-capability node promotes to Leader
    // Initial: Node1 (Leader), Node2 (Member), Node3 (Member)
    // After Node1 leaves: Node2 should become Leader

    let node2_profile = create_node_profile(NodeMobility::SemiMobile, 4, 2048, 40, Some(80));
    let node3_profile = create_node_profile(NodeMobility::Mobile, 2, 1024, 70, Some(30));

    let strategy = Arc::new(DynamicHierarchyStrategy::new(
        HierarchyLevel::Cell,
        ElectionConfig::default(),
        false,
    ));

    // Initial state: Node2 sees Node3 only (Node1 has left)
    let node3_beacon = create_beacon(
        "node3",
        GeoPosition::new(37.7760, -122.4185),
        HierarchyLevel::Cell,
        node3_profile.clone(),
    );

    let node2_nearby = vec![node3_beacon.clone()];
    let node2_role_after_leader_left = strategy.determine_role(&node2_profile, &node2_nearby);

    // Verify node2 promotes to Leader when node1 leaves
    assert_eq!(
        node2_role_after_leader_left,
        NodeRole::Leader,
        "Node2 should become Leader when higher-capability leader leaves"
    );

    // Node3 still sees Node2
    let node2_beacon = create_beacon(
        "node2",
        GeoPosition::new(37.7750, -122.4195),
        HierarchyLevel::Cell,
        node2_profile,
    );

    let node3_nearby = vec![node2_beacon];
    let node3_role = strategy.determine_role(&node3_profile, &node3_nearby);

    // Verify node3 remains Member
    assert_eq!(node3_role, NodeRole::Member, "Node3 should remain Member");
}

#[tokio::test]
async fn test_lateral_peer_discovery() {
    // Test Scenario: Multiple nodes at same hierarchy level discover each other
    // All nodes at Cell level should recognize each other as lateral peers

    let node1_profile = create_node_profile(NodeMobility::Static, 8, 4096, 20, None);

    // Create beacons for lateral peers (same hierarchy level)
    let lateral_peer1 = create_beacon(
        "lateral1",
        GeoPosition::new(37.7750, -122.4195),
        HierarchyLevel::Cell, // Same level
        node1_profile.clone(),
    );

    let lateral_peer2 = create_beacon(
        "lateral2",
        GeoPosition::new(37.7760, -122.4185),
        HierarchyLevel::Cell, // Same level
        node1_profile.clone(),
    );

    // Create a beacon at different level (should not be lateral peer)
    let higher_level_peer = create_beacon(
        "cohort_node",
        GeoPosition::new(37.7765, -122.4180),
        HierarchyLevel::Cohort, // Different level
        node1_profile,
    );

    let nearby_beacons = [
        lateral_peer1.clone(),
        lateral_peer2.clone(),
        higher_level_peer,
    ];

    // Count lateral peers (same level)
    let lateral_peer_count = nearby_beacons
        .iter()
        .filter(|b| b.hierarchy_level == HierarchyLevel::Cell)
        .count();

    assert_eq!(
        lateral_peer_count, 2,
        "Should discover 2 lateral peers at same hierarchy level"
    );
}

#[tokio::test]
async fn test_hybrid_strategy_static_level_dynamic_role() {
    // Test Scenario: Hybrid strategy with static level but dynamic role election
    // Nodes stay at assigned level but elect leader dynamically

    let high_capability_profile = create_node_profile(NodeMobility::Static, 8, 4096, 20, None);
    let low_capability_profile = create_node_profile(NodeMobility::Mobile, 2, 1024, 70, Some(30));

    let strategy = Arc::new(HybridHierarchyStrategy::with_static_level_dynamic_role(
        HierarchyLevel::Cohort,
        ElectionConfig::default(),
    ));

    // High capability node
    let level = strategy.determine_level(&high_capability_profile);
    assert_eq!(
        level,
        HierarchyLevel::Cohort,
        "Hybrid strategy should maintain static level"
    );

    // Create nearby peer beacon
    let low_cap_beacon = create_beacon(
        "low_cap",
        GeoPosition::new(37.7750, -122.4195),
        HierarchyLevel::Cohort,
        low_capability_profile.clone(),
    );

    let nearby = vec![low_cap_beacon];

    // High capability node should become Leader
    let role = strategy.determine_role(&high_capability_profile, &nearby);
    assert_eq!(
        role,
        NodeRole::Leader,
        "High capability node should be elected Leader"
    );

    // Low capability node sees high capability peer
    let high_cap_beacon = create_beacon(
        "high_cap",
        GeoPosition::new(37.7755, -122.4190),
        HierarchyLevel::Cohort,
        high_capability_profile,
    );

    let low_cap_nearby = vec![high_cap_beacon];
    let low_cap_role = strategy.determine_role(&low_capability_profile, &low_cap_nearby);

    assert_eq!(
        low_cap_role,
        NodeRole::Member,
        "Low capability node should be Member"
    );
}

#[tokio::test]
async fn test_static_hierarchy_fixed_roles() {
    // Test Scenario: Static hierarchy maintains assigned roles regardless of capabilities

    let high_capability_profile = create_node_profile(NodeMobility::Static, 8, 4096, 20, None);
    let low_capability_profile = create_node_profile(NodeMobility::Mobile, 2, 1024, 70, Some(30));

    // Create static strategy with Member role (even though node has high capability)
    let static_strategy = Arc::new(StaticHierarchyStrategy {
        assigned_level: HierarchyLevel::Cell,
        assigned_role: NodeRole::Member,
    });

    // High capability node with static Member assignment
    let level = static_strategy.determine_level(&high_capability_profile);
    assert_eq!(level, HierarchyLevel::Cell);

    // Create nearby beacons (shouldn't affect static role)
    let beacon = create_beacon(
        "other",
        GeoPosition::new(37.7750, -122.4195),
        HierarchyLevel::Cell,
        low_capability_profile,
    );

    let nearby = vec![beacon];
    let role = static_strategy.determine_role(&high_capability_profile, &nearby);

    // Should maintain static Member role despite high capability
    assert_eq!(
        role,
        NodeRole::Member,
        "Static strategy should maintain assigned role regardless of capabilities"
    );

    // Verify no transitions allowed
    assert!(
        !static_strategy.can_transition(HierarchyLevel::Cell, HierarchyLevel::Cohort),
        "Static strategy should not allow level transitions"
    );
}

#[tokio::test]
async fn test_hybrid_strategy_adaptive_promotion() {
    // Test Scenario: Hybrid strategy allows promotion when no higher-level peers exist

    let node_profile = create_node_profile(NodeMobility::Static, 4, 2048, 30, None);

    let strategy = Arc::new(HybridHierarchyStrategy::with_adaptive_promotion(
        HierarchyLevel::Cell,
        NodeRole::Member,
        ElectionConfig::default(),
    ));

    // Verify baseline level
    let level = strategy.determine_level(&node_profile);
    assert_eq!(level, HierarchyLevel::Cell);

    // Verify promotion is allowed (but only 1 level)
    assert!(
        strategy.can_transition(HierarchyLevel::Cell, HierarchyLevel::Cohort),
        "Should allow 1-level promotion"
    );

    assert!(
        !strategy.can_transition(HierarchyLevel::Cell, HierarchyLevel::Federation),
        "Should not allow 2-level promotion"
    );

    // Verify demotion is not allowed
    assert!(
        !strategy.can_transition(HierarchyLevel::Cohort, HierarchyLevel::Cell),
        "Should not allow demotion"
    );
}

#[tokio::test]
async fn test_multi_node_role_convergence() {
    // Test Scenario: 5 nodes converge on stable role assignments
    // Simulates realistic network with mixed capabilities

    let nodes = [
        (
            "node1",
            NodeProfile {
                mobility: NodeMobility::Static,
                resources: NodeResources {
                    cpu_cores: 8,
                    memory_mb: 8192,
                    bandwidth_mbps: 1000,
                    cpu_usage_percent: 10,
                    memory_usage_percent: 20,
                    battery_percent: None, // AC powered
                },
                can_parent: true,
                prefer_leaf: false,
                parent_priority: 200, // High priority
            },
        ), // Highest - Best hardware, AC powered, high priority
        (
            "node2",
            NodeProfile {
                mobility: NodeMobility::SemiMobile,
                resources: NodeResources {
                    cpu_cores: 4,
                    memory_mb: 2048,
                    bandwidth_mbps: 100,
                    cpu_usage_percent: 50,
                    memory_usage_percent: 60,
                    battery_percent: Some(70),
                },
                can_parent: true,
                prefer_leaf: false,
                parent_priority: 100,
            },
        ),
        (
            "node3",
            NodeProfile {
                mobility: NodeMobility::Mobile,
                resources: NodeResources {
                    cpu_cores: 2,
                    memory_mb: 1024,
                    bandwidth_mbps: 50,
                    cpu_usage_percent: 70,
                    memory_usage_percent: 80,
                    battery_percent: Some(40),
                },
                can_parent: false, // Cannot be parent
                prefer_leaf: false,
                parent_priority: 50,
            },
        ),
        (
            "node4",
            NodeProfile {
                mobility: NodeMobility::Mobile,
                resources: NodeResources {
                    cpu_cores: 1,
                    memory_mb: 512,
                    bandwidth_mbps: 25,
                    cpu_usage_percent: 85,
                    memory_usage_percent: 90,
                    battery_percent: Some(20),
                },
                can_parent: false,
                prefer_leaf: true,
                parent_priority: 10,
            },
        ),
        (
            "node5",
            NodeProfile {
                mobility: NodeMobility::Mobile,
                resources: NodeResources {
                    cpu_cores: 1,
                    memory_mb: 256,
                    bandwidth_mbps: 10,
                    cpu_usage_percent: 95,
                    memory_usage_percent: 95,
                    battery_percent: Some(5),
                },
                can_parent: false,
                prefer_leaf: true,
                parent_priority: 5,
            },
        ), // Lowest - Very constrained device
    ];

    let strategy = Arc::new(DynamicHierarchyStrategy::new(
        HierarchyLevel::Cell,
        ElectionConfig::default(),
        false,
    ));

    // Create beacons for all nodes
    let beacons: Vec<GeographicBeacon> = nodes
        .iter()
        .enumerate()
        .map(|(i, (name, profile))| {
            let lat = 37.775 + (i as f64 * 0.001);
            let lon = -122.419 + (i as f64 * 0.001);
            create_beacon(
                name,
                GeoPosition::new(lat, lon),
                HierarchyLevel::Cell,
                profile.clone(),
            )
        })
        .collect();

    // Determine roles for each node
    let mut roles = Vec::new();
    for (i, (_, profile)) in nodes.iter().enumerate() {
        // Each node sees all other nodes
        let nearby: Vec<GeographicBeacon> = beacons
            .iter()
            .enumerate()
            .filter(|(j, _)| *j != i)
            .map(|(_, b)| b.clone())
            .collect();

        let role = strategy.determine_role(profile, &nearby);
        roles.push(role);
    }

    // Verify convergence
    let leader_count = roles.iter().filter(|r| **r == NodeRole::Leader).count();
    let member_count = roles.iter().filter(|r| **r == NodeRole::Member).count();

    assert_eq!(leader_count, 1, "Exactly one node should be elected Leader");
    assert_eq!(member_count, 4, "Four nodes should be Members");

    // Verify highest capability node is Leader
    assert_eq!(
        roles[0],
        NodeRole::Leader,
        "Node1 (highest capability) should be Leader"
    );
}

#[tokio::test]
async fn test_role_stability_with_hysteresis() {
    // Test Scenario: Verify 10% hysteresis prevents role flapping
    // A node must be >10% better than best peer to become Leader

    let strategy = Arc::new(DynamicHierarchyStrategy::new(
        HierarchyLevel::Cell,
        ElectionConfig::default(),
        false,
    ));

    // Peer with similar capability but slightly better
    let peer1_profile = create_node_profile(NodeMobility::Static, 8, 4096, 30, None);
    let peer2_profile = create_node_profile(NodeMobility::Static, 8, 4096, 25, None); // Slightly better

    let peer1_beacon = create_beacon(
        "peer1",
        GeoPosition::new(37.7750, -122.4195),
        HierarchyLevel::Cell,
        peer1_profile,
    );

    // Peer2 is only slightly better - should NOT become Leader due to hysteresis
    let peer2_role = strategy.determine_role(&peer2_profile, std::slice::from_ref(&peer1_beacon));
    assert_eq!(
        peer2_role,
        NodeRole::Member,
        "Node with slightly better capability should not become Leader (hysteresis prevents flapping)"
    );

    // Now test with a significantly better node (much lower CPU usage, high priority)
    let much_better_profile = NodeProfile {
        mobility: NodeMobility::Static,
        resources: NodeResources {
            cpu_cores: 8,
            memory_mb: 8192,
            bandwidth_mbps: 1000,
            cpu_usage_percent: 10,
            memory_usage_percent: 15,
            battery_percent: None,
        },
        can_parent: true,
        prefer_leaf: false,
        parent_priority: 200,
    };

    let much_better_role =
        strategy.determine_role(&much_better_profile, std::slice::from_ref(&peer1_beacon));

    // Much better node should become Leader (significantly better score)
    assert_eq!(
        much_better_role,
        NodeRole::Leader,
        "Node with significantly better capability should become Leader"
    );
}

#[tokio::test]
async fn test_coalition_tier_election_e2e() {
    // ADR-066 tier 4: Coalition. Mirror the existing 3-node election test at the
    // Coalition tier to confirm the dynamic strategy is tier-generic. Use
    // priority_weights to make coord1 unambiguously the best so 10% hysteresis
    // doesn't cause flapping.
    let coord1_profile = NodeProfile {
        mobility: NodeMobility::Static,
        resources: NodeResources {
            cpu_cores: 16,
            memory_mb: 16384,
            bandwidth_mbps: 1000,
            cpu_usage_percent: 5,
            memory_usage_percent: 10,
            battery_percent: None, // AC powered
        },
        can_parent: true,
        prefer_leaf: false,
        parent_priority: 250,
    };
    let coord2_profile = create_node_profile(NodeMobility::SemiMobile, 8, 8192, 60, Some(50));
    let coord3_profile = create_node_profile(NodeMobility::Mobile, 4, 4096, 80, Some(20));

    let strategy = Arc::new(DynamicHierarchyStrategy::new(
        HierarchyLevel::Coalition,
        ElectionConfig::default(),
        true, // Allow Federation <-> Coalition transitions per ADR-066
    ));

    let coord2_beacon = create_beacon(
        "coalition-2",
        GeoPosition::new(37.7755, -122.4190),
        HierarchyLevel::Coalition,
        coord2_profile.clone(),
    );
    let coord3_beacon = create_beacon(
        "coalition-3",
        GeoPosition::new(37.7760, -122.4185),
        HierarchyLevel::Coalition,
        coord3_profile,
    );

    // Highest-capability Coalition coordinator becomes Leader.
    let role = strategy.determine_role(&coord1_profile, &[coord2_beacon, coord3_beacon.clone()]);
    assert_eq!(role, NodeRole::Leader);

    // A lower-capability candidate sees coord1 (higher) and becomes Member.
    let coord1_beacon = create_beacon(
        "coalition-1",
        GeoPosition::new(37.7750, -122.4195),
        HierarchyLevel::Coalition,
        coord1_profile,
    );
    let coord2_role = strategy.determine_role(&coord2_profile, &[coord1_beacon, coord3_beacon]);
    assert_eq!(coord2_role, NodeRole::Member);
}

#[tokio::test]
async fn test_federation_to_coalition_uplink_e2e() {
    // ADR-066: a Federation node should treat a Coalition coordinator as its
    // parent tier. With the dynamic strategy at Federation level, the presence
    // of higher-tier Coalition beacons must not flip the Federation node out of
    // Leader at its own level — Coalition is parent, not peer.
    let federation_profile = create_node_profile(NodeMobility::Static, 16, 16384, 20, None);
    let coalition_profile = create_node_profile(NodeMobility::Static, 16, 16384, 15, None);

    let strategy = Arc::new(DynamicHierarchyStrategy::new(
        HierarchyLevel::Federation,
        ElectionConfig::default(),
        false,
    ));

    // No Federation-level peers, but a Coalition coordinator is visible.
    let coalition_beacon = create_beacon(
        "coalition-1",
        GeoPosition::new(37.7750, -122.4195),
        HierarchyLevel::Coalition,
        coalition_profile,
    );

    let role = strategy.determine_role(&federation_profile, &[coalition_beacon]);
    // With no same-level peers the Federation node is Standalone (its parent
    // is Coalition, not a same-level competitor).
    assert_eq!(
        role,
        NodeRole::Standalone,
        "Federation node should be Standalone when only higher-tier (Coalition) beacons are visible"
    );
}

/// End-to-end Federation→Coalition glue: beacon discovery → peer selection →
/// routing decision, all three layers wired together for the new tier.
///
/// `test_federation_to_coalition_uplink_e2e` above covers the role-determination
/// half (a Federation node treats Coalition as parent, not same-tier peer);
/// `src/routing/router.rs::test_federation_with_coalition_parent_consumes_and_forwards`
/// covers the routing half (`selected_peer = Some(Coalition)` → `ConsumeAndForward`).
/// peat-mesh#208 QA [SUGGESTION] flagged that the *glue between them* —
/// beacon discovery selecting a Coalition beacon as the Federation node's
/// `selected_peer`, which then drives routing — wasn't tested end-to-end.
///
/// This test closes that gap by chaining the three layers in-test:
///
///   1. Build a candidate-beacon list including same-tier Federation peers
///      and a higher-tier Coalition coordinator.
///   2. Run `PeerSelector` (tier-aware uplink filter — `can_be_parent_of`
///      rejects same-tier candidates) — must return the Coalition beacon.
///   3. Wrap that beacon in `SelectedPeer`, build a `TopologyState` for the
///      Federation node, and run `SelectiveRouter::route` on an upward
///      telemetry packet — must yield `ConsumeAndForward { next_hop = the
///      Coalition coordinator }`.
///
/// If any layer regresses for Coalition specifically — e.g., `PeerSelector`
/// drops Coalition because `can_be_parent_of` forgets the new variant; or
/// `SelectiveRouter::route` reverts `is_hq_level` to only-`Federation` — this
/// test fails at the boundary that broke. peat-mesh#208 QA [SUGGESTION] safety
/// net.
#[tokio::test]
async fn test_federation_to_coalition_glue_e2e_beacon_selection_routing() {
    use peat_mesh::routing::{RoutingDecision, SelectiveRouter};
    use peat_mesh::topology::{PeerSelector, SelectedPeer, SelectionConfig, TopologyState};
    use std::time::Instant;

    // 1. Set up the Federation node's own state.
    let own_position = GeoPosition::new(37.7749, -122.4194);
    let own_level = HierarchyLevel::Federation;

    // 2. Candidate beacons: two same-tier Federation peers and one Coalition
    //    coordinator. The selector's `is_valid_peer` filter requires a
    //    `can_be_parent_of(own_level)`-true beacon, so the same-tier peers
    //    must be rejected and only the Coalition coordinator must score.
    let federation_peer_a = create_beacon(
        "federation-peer-a",
        GeoPosition::new(37.7750, -122.4195),
        HierarchyLevel::Federation,
        create_node_profile(NodeMobility::Static, 16, 16384, 30, None),
    );
    let federation_peer_b = create_beacon(
        "federation-peer-b",
        GeoPosition::new(37.7751, -122.4196),
        HierarchyLevel::Federation,
        create_node_profile(NodeMobility::Static, 16, 16384, 25, None),
    );
    let coalition_coordinator = create_beacon(
        "coalition-coord",
        GeoPosition::new(37.7752, -122.4197),
        HierarchyLevel::Coalition,
        create_node_profile(NodeMobility::Static, 32, 32768, 20, None),
    );
    let beacons = vec![
        federation_peer_a,
        federation_peer_b,
        coalition_coordinator.clone(),
    ];

    // 3. Peer-selection layer: must pick the Coalition coordinator.
    let selector = PeerSelector::new(SelectionConfig::distributed(), own_position, own_level);
    let selected = selector.select_peer(&beacons).expect(
        "selector must return a candidate — Coalition coordinator should pass \
         can_be_parent_of(Federation) (same-tier Federation peers must be filtered out)",
    );
    assert_eq!(
        selected.beacon.node_id, "coalition-coord",
        "selector must pick the Coalition coordinator over same-tier Federation \
         peers — Federation cannot uplink to Federation. got node_id={:?}",
        selected.beacon.node_id
    );
    assert_eq!(
        selected.beacon.hierarchy_level,
        HierarchyLevel::Coalition,
        "selected uplink must be Coalition tier (not same-tier Federation)"
    );

    // 4. Build a TopologyState with that selected peer, mirroring the
    //    `selected_peer` field the live `TopologyBuilder` would write.
    let selected_peer = SelectedPeer {
        node_id: selected.beacon.node_id.clone(),
        beacon: selected.beacon.clone(),
        selected_at: Instant::now(),
    };
    // Struct-update syntax so clippy's `field-reassign-with-default` lint
    // is happy. No `config` field on TopologyState — config is held by the
    // TopologyBuilder, not the per-tick state snapshot.
    let state = TopologyState {
        hierarchy_level: own_level,
        role: NodeRole::Leader,
        selected_peer: Some(selected_peer),
        ..Default::default()
    };

    // 5. Routing layer: upward telemetry must produce ConsumeAndForward
    //    with the Coalition coordinator as `next_hop`.
    let router = SelectiveRouter::new();
    let packet = peat_mesh::routing::DataPacket::telemetry("sensor-1", vec![1, 2, 3]);
    let decision = router.route(&packet, &state, "federation-leader");
    match decision {
        RoutingDecision::ConsumeAndForward { ref next_hop } => {
            assert_eq!(
                next_hop, "coalition-coord",
                "Federation node with Coalition selected_peer must ConsumeAndForward up \
                 to that Coalition coordinator. got next_hop={next_hop:?}"
            );
        }
        other => panic!("Federation→Coalition uplink must yield ConsumeAndForward, got {other:?}"),
    }
}
