package server

import (
	"context"
	"fmt"
	"net/netip"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"

	nbdns "github.com/netbirdio/netbird/dns"
	rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
	"github.com/netbirdio/netbird/management/server/affectedpeers"
	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
	nbpeer "github.com/netbirdio/netbird/management/server/peer"
	"github.com/netbirdio/netbird/management/server/posture"
	"github.com/netbirdio/netbird/management/server/store"
	"github.com/netbirdio/netbird/management/server/types"
	"github.com/netbirdio/netbird/route"
)

// resolveAffected is a test helper for the resolver's Load+Expand, used where a
// test asserts on the fully expanded affected peer set.
func resolveAffected(t *testing.T, s store.Store, accountID string, change affectedpeers.Change) []string {
	t.Helper()
	ctx := context.Background()
	snap, err := affectedpeers.Load(ctx, s, accountID, change)
	require.NoError(t, err)
	return snap.Expand(ctx, accountID, change)
}

// Thin test adapters over affectedpeers.Collect, preserving the (groups, peers)
// shape these tests assert on after the resolver was unified.
func collectGroupChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs []string) ([]string, []string) {
	return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs})
}

func collectPeerChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs, changedPeerIDs []string) ([]string, []string) {
	return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs, ChangedPeerIDs: changedPeerIDs})
}

func collectPostureCheckAffectedGroupsAndPeers(ctx context.Context, s store.Store, accountID, postureCheckID string) ([]string, []string) {
	return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{PostureCheckIDs: []string{postureCheckID}})
}

// setupAffectedPeersTest creates a manager with a clean account (default policy deleted)
// and 5 peers, each in its own group: peer0->group0, peer1->group1, ..., peer4->group4.
func setupAffectedPeersTest(t *testing.T) (*DefaultAccountManager, store.Store, string, []string, []string) {
	t.Helper()

	manager, _, err := createManager(t)
	require.NoError(t, err)

	account, err := createAccount(manager, "affected_test", userID, "")
	require.NoError(t, err)

	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
	require.NoError(t, err)

	peerIDs := make([]string, 5)
	for i := 0; i < 5; i++ {
		peer := addPeerToAccount(t, manager, accountID, setupKey.Key)
		peerIDs[i] = peer.ID
	}

	groupIDs := make([]string, 5)
	for i := 0; i < 5; i++ {
		g := &types.Group{
			ID:    affectedGroupID(i),
			Name:  affectedGroupName(i),
			Peers: []string{peerIDs[i]},
		}
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
		groupIDs[i] = g.ID
	}

	return manager, manager.Store, accountID, peerIDs, groupIDs
}

func affectedGroupID(i int) string   { return fmt.Sprintf("affected-grp-%d", i) }
func affectedGroupName(i int) string { return fmt.Sprintf("AffectedGroup%d", i) }

func TestCollectGroupChange_PolicyLinked(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:             true,
				Sources:             []string{groupIDs[0]},
				Destinations:        []string{groupIDs[1]},
				SourceResource:      types.Resource{ID: peerIDs[0], Type: types.ResourceTypePeer},
				DestinationResource: types.Resource{ID: peerIDs[1], Type: types.ResourceTypePeer},
				Bidirectional:       true,
				Action:              types.PolicyTrafficActionAccept,
			},
			{
				Enabled:             true,
				Sources:             []string{groupIDs[0]},
				Destinations:        []string{groupIDs[1]},
				SourceResource:      types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost},
				DestinationResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypeHost},
				Bidirectional:       true,
				Action:              types.PolicyTrafficActionAccept,
			},
			{
				Enabled:             true,
				Sources:             []string{groupIDs[0]},
				Destinations:        []string{groupIDs[1]},
				SourceResource:      types.Resource{ID: "", Type: types.ResourceTypePeer},
				DestinationResource: types.Resource{ID: "", Type: types.ResourceTypePeer},
				Bidirectional:       true,
				Action:              types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
	assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
	assert.ElementsMatch(t, directPeers, []string{peerIDs[1]})

	groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
	assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
	assert.ElementsMatch(t, directPeers, []string{peerIDs[0]})

	groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
	assert.Empty(t, groups)
	assert.Empty(t, directPeers)
}

func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:             true,
				Sources:             []string{groupIDs[0]},
				SourceResource:      types.Resource{ID: peerIDs[3], Type: types.ResourceTypePeer},
				DestinationResource: types.Resource{ID: peerIDs[4], Type: types.ResourceTypePeer},
				Destinations:        []string{groupIDs[1]},
				Action:              types.PolicyTrafficActionAccept,
			},
			{
				Enabled:             true,
				Sources:             []string{groupIDs[0]},
				SourceResource:      types.Resource{ID: peerIDs[1], Type: types.ResourceTypeHost},
				DestinationResource: types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost},
				Destinations:        []string{groupIDs[1]},
				Action:              types.PolicyTrafficActionAccept,
			},
			{
				Enabled:             true,
				Sources:             []string{groupIDs[0]},
				SourceResource:      types.Resource{ID: "", Type: types.ResourceTypePeer},
				DestinationResource: types.Resource{ID: "", Type: types.ResourceTypePeer},
				Destinations:        []string{groupIDs[1]},
				Action:              types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
	assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
	assert.ElementsMatch(t, directPeers, []string{peerIDs[4]})

	groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
	assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
	assert.ElementsMatch(t, directPeers, []string{peerIDs[3]})

	groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
	assert.Empty(t, groups)
	assert.Empty(t, directPeers)
}

func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T) {
	manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:        true,
				Sources:        []string{groupIDs[0]},
				SourceResource: types.Resource{ID: "some-domain", Type: types.ResourceTypeDomain},
				Destinations:   []string{groupIDs[1]},
				Action:         types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
	assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
	assert.Empty(t, directPeers, "non-peer resources should not produce direct peer IDs")
}

func TestCollectGroupChange_RouteLinked(t *testing.T) {
	manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.0.0.0/24"),
		route.IPv4Network,
		nil,
		"",
		[]string{groupIDs[0]},
		"test route",
		"testnet",
		false,
		9999,
		[]string{groupIDs[1]},
		[]string{groupIDs[2]},
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
	assert.Contains(t, groups, groupIDs[0])
	assert.Contains(t, groups, groupIDs[1])
	assert.Contains(t, groups, groupIDs[2])

	groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
	assert.Contains(t, groups, groupIDs[0])
	assert.Contains(t, groups, groupIDs[1])
	assert.Contains(t, groups, groupIDs[2])

	groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]})
	assert.Empty(t, groups)
}

func TestCollectGroupChange_RouteWithDirectPeer(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.1.0.0/24"),
		route.IPv4Network,
		nil,
		peerIDs[4],
		nil,
		"test route peer",
		"testnet2",
		false,
		9999,
		[]string{groupIDs[1]},
		nil,
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
	assert.Contains(t, groups, groupIDs[1])
	assert.Contains(t, directPeers, peerIDs[4])
}

func TestCollectGroupChange_NameServerGroupLinked(t *testing.T) {
	manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.CreateNameServerGroup(ctx, accountID, "ns1", "NS Group 1",
		[]nbdns.NameServer{{
			IP:     netip.MustParseAddr("1.1.1.1"),
			NSType: nbdns.UDPNameServerType,
			Port:   nbdns.DefaultDNSPort,
		}},
		[]string{groupIDs[0]},
		true, nil, true, userID, false,
	)
	require.NoError(t, err)

	groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
	assert.Contains(t, groups, groupIDs[0])

	groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
	assert.Empty(t, groups)
}

func TestCollectGroupChange_DNSSettingsLinked(t *testing.T) {
	manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{
		DisabledManagementGroups: []string{groupIDs[2]},
	})
	require.NoError(t, err)

	groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
	assert.Contains(t, groups, groupIDs[2])

	groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
	assert.Empty(t, groups)
}

func TestCollectGroupChange_NetworkRouterLinked(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	net1 := &networkTypes.Network{
		ID:        "net-test-1",
		AccountID: accountID,
		Name:      "test-network",
	}
	err := manager.Store.SaveNetwork(ctx, net1)
	require.NoError(t, err)

	err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
		ID:         "router1",
		NetworkID:  net1.ID,
		AccountID:  accountID,
		PeerGroups: []string{groupIDs[0]},
		Peer:       peerIDs[3],
		Enabled:    true,
	})
	require.NoError(t, err)

	groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
	assert.Contains(t, groups, groupIDs[0])
	assert.Contains(t, directPeers, peerIDs[3])

	groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
	assert.Empty(t, groups)
	assert.Empty(t, directPeers)
}

func TestCollectGroupChange_NetworkRouterPeerOnlyNoGroups(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	net1 := &networkTypes.Network{
		ID:        "net-peer-only",
		AccountID: accountID,
		Name:      "peer-only-network",
	}
	err := manager.Store.SaveNetwork(ctx, net1)
	require.NoError(t, err)

	// Router with only a direct peer, no PeerGroups
	err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
		ID:        "router-peer-only",
		NetworkID: net1.ID,
		AccountID: accountID,
		Peer:      peerIDs[4],
		Enabled:   true,
	})
	require.NoError(t, err)

	// None of the groups should match since router has no PeerGroups
	for i := 0; i < 5; i++ {
		groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[i]})
		assert.Empty(t, groups, "group%d should not match router with only direct peer", i)
		assert.Empty(t, directPeers, "group%d should not produce direct peers", i)
	}
}

func TestCollectGroupChange_MultipleEntities(t *testing.T) {
	manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{groupIDs[0]},
				Destinations:  []string{groupIDs[1]},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	_, err = manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.2.0.0/24"),
		route.IPv4Network,
		nil,
		"",
		[]string{groupIDs[2]},
		"multi route",
		"multinet",
		false,
		9999,
		[]string{groupIDs[3]},
		nil,
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
	assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
	assert.Empty(t, directPeers)

	groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]})
	assert.ElementsMatch(t, groups, []string{groupIDs[2], groupIDs[3]})
	assert.Empty(t, directPeers)
}

func TestCollectGroupChange_MultipleNameServerGroups_OnlyLinkedAffected(t *testing.T) {
	manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	// Create two nameserver groups using different groups
	_, err := manager.CreateNameServerGroup(ctx, accountID, "ns-a", "NS-A",
		[]nbdns.NameServer{{
			IP:     netip.MustParseAddr("1.1.1.1"),
			NSType: nbdns.UDPNameServerType,
			Port:   nbdns.DefaultDNSPort,
		}},
		[]string{groupIDs[0]},
		true, nil, true, userID, false,
	)
	require.NoError(t, err)

	_, err = manager.CreateNameServerGroup(ctx, accountID, "ns-b", "NS-B",
		[]nbdns.NameServer{{
			IP:     netip.MustParseAddr("8.8.8.8"),
			NSType: nbdns.UDPNameServerType,
			Port:   nbdns.DefaultDNSPort,
		}},
		[]string{groupIDs[2]},
		true, nil, true, userID, false,
	)
	require.NoError(t, err)

	// Changing group0 should only find group0 (from ns-a), not group2 (from ns-b)
	groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
	assert.Contains(t, groups, groupIDs[0])
	assert.NotContains(t, groups, groupIDs[2])

	groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
	assert.Contains(t, groups, groupIDs[2])
	assert.NotContains(t, groups, groupIDs[0])

	// Unrelated group
	groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[4]})
	assert.Empty(t, groups)
}

func TestResolveAffectedPeers_PolicyBetweenTwoGroups(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{groupIDs[0]},
				Destinations:  []string{groupIDs[1]},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)

	result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)

	// peerIDs[2] is unrelated to the route; only its own map can change.
	result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
	assert.ElementsMatch(t, []string{peerIDs[2]}, result)
}

func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:      true,
				Sources:      []string{groupIDs[0], groupIDs[1]},
				Destinations: []string{groupIDs[2]},
				Action:       types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2]}, result)
}

func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.3.0.0/24"),
		route.IPv4Network,
		nil,
		"",
		[]string{groupIDs[0]},
		"test route",
		"routenet",
		false,
		9999,
		[]string{groupIDs[1]},
		nil,
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)

	result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)

	// peerIDs[2] is in no policy; only its own map can change, so it refreshes itself.
	result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
	assert.ElementsMatch(t, []string{peerIDs[2]}, result)
}

func TestResolveAffectedPeers_RouteWithDirectPeer(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.4.0.0/24"),
		route.IPv4Network,
		nil,
		peerIDs[4],
		nil,
		"route with peer",
		"routenet2",
		false,
		9999,
		[]string{groupIDs[1]},
		nil,
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]})
	assert.ElementsMatch(t, []string{peerIDs[1], peerIDs[4]}, result)
}

func TestResolveAffectedPeers_RouteWithAccessControlGroups(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.7.0.0/24"),
		route.IPv4Network,
		nil,
		"",
		[]string{groupIDs[0]},
		"acl route",
		"aclnet",
		false,
		9999,
		[]string{groupIDs[1]},
		[]string{groupIDs[2]},
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	// peer2 is only in AccessControlGroups, still should be affected
	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)

	// peer3 is unrelated to the route; only its own map can change.
	result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[3]})
	assert.ElementsMatch(t, []string{peerIDs[3]}, result)
}

func TestResolveAffectedPeers_NetworkRouter(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	net1 := &networkTypes.Network{
		ID:        "net-test-2",
		AccountID: accountID,
		Name:      "test-net",
	}
	err := manager.Store.SaveNetwork(ctx, net1)
	require.NoError(t, err)

	err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
		ID:         "router-test",
		NetworkID:  net1.ID,
		AccountID:  accountID,
		PeerGroups: []string{groupIDs[0]},
		Peer:       peerIDs[3],
		Enabled:    true,
	})
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[3]}, result)
}

func TestResolveAffectedPeers_NameServerGroup(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.CreateNameServerGroup(ctx, accountID, "ns-test", "NS Test",
		[]nbdns.NameServer{{
			IP:     netip.MustParseAddr("8.8.8.8"),
			NSType: nbdns.UDPNameServerType,
			Port:   nbdns.DefaultDNSPort,
		}},
		[]string{groupIDs[0]},
		true, nil, true, userID, false,
	)
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.Contains(t, result, peerIDs[0])
}

func TestResolveAffectedPeers_DNSSettings(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{
		DisabledManagementGroups: []string{groupIDs[0]},
	})
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.Contains(t, result, peerIDs[0])
}

func TestResolveAffectedPeers_PeerInMultipleGroups(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0])
	require.NoError(t, err)

	_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:      true,
				Sources:      []string{groupIDs[0]},
				Destinations: []string{groupIDs[2]},
				Action:       types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:      true,
				Sources:      []string{groupIDs[1]},
				Destinations: []string{groupIDs[3]},
				Action:       types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	// peer0 is in group0 AND group1, so both policies apply. A peer change folds
	// only the changed peer plus the opposite side of each rule: group2 (peer2) via
	// the group0 policy and group3 (peer3) via the group1 policy. peer1, a co-member
	// of group1, is a sibling of the changed peer and must NOT refresh.
	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[3]}, result)
	assert.NotContains(t, result, peerIDs[1], "co-member of the changed peer's group must not refresh")
}

func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{groupIDs[0]},
				Destinations:  []string{groupIDs[1]},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{groupIDs[2]},
				Destinations:  []string{groupIDs[3]},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0], peerIDs[2]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[1], peerIDs[3]}, result)
}

func TestResolveAffectedPeers_SharedGroupAcrossPolicyAndRoute(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{groupIDs[0]},
				Destinations:  []string{groupIDs[1]},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	_, err = manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.5.0.0/24"),
		route.IPv4Network,
		nil,
		"",
		[]string{groupIDs[2]},
		"shared group route",
		"sharednet",
		false,
		9999,
		[]string{groupIDs[0]},
		nil,
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	// group0 is shared: policy gives peer0+peer1, route gives peer0+peer2
	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)
}

func TestResolveAffectedPeers_NoDuplicates(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0])
	require.NoError(t, err)
	err = manager.GroupAddPeer(ctx, accountID, groupIDs[2], peerIDs[0])
	require.NoError(t, err)

	_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:      true,
				Sources:      []string{groupIDs[0], groupIDs[1]},
				Destinations: []string{groupIDs[2]},
				Action:       types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	count := 0
	for _, id := range result {
		if id == peerIDs[0] {
			count++
		}
	}
	assert.Equal(t, 1, count, "peer0 should appear exactly once")
}

func TestCollectPostureCheckAffected_LinkedToPolicy(t *testing.T) {
	manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	// Create the posture check in the store so the policy validation keeps the reference.
	err := s.SavePostureChecks(ctx, &posture.Checks{
		ID:        "pc-1",
		Name:      "test-posture-check",
		AccountID: accountID,
	})
	require.NoError(t, err)

	policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled:             true,
		SourcePostureChecks: []string{"pc-1"},
		Rules: []*types.PolicyRule{
			{
				Enabled:      true,
				Sources:      []string{groupIDs[0]},
				Destinations: []string{groupIDs[1]},
				Action:       types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)
	_ = policy

	groups, directPeers := collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-1")
	assert.Contains(t, groups, groupIDs[0])
	assert.Contains(t, groups, groupIDs[1])
	assert.Empty(t, directPeers)

	// Different posture check ID should not match
	groups, directPeers = collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-other")
	assert.Empty(t, groups)
	assert.Empty(t, directPeers)
}

func TestAffectedPeers_IsolatedPolicies(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{groupIDs[0]},
				Destinations:  []string{groupIDs[1]},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{groupIDs[2]},
				Destinations:  []string{groupIDs[3]},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
	assert.NotContains(t, result, peerIDs[2])
	assert.NotContains(t, result, peerIDs[3])

	result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
	assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result)
	assert.NotContains(t, result, peerIDs[0])
	assert.NotContains(t, result, peerIDs[1])

	// peerIDs[4] is in neither isolated policy; only its own map can change.
	result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[4]})
	assert.ElementsMatch(t, []string{peerIDs[4]}, result)
}

func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{groupIDs[0]},
				Destinations:  []string{groupIDs[1]},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	_, err = manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.6.0.0/24"),
		route.IPv4Network,
		nil,
		"",
		[]string{groupIDs[2]},
		"isolated route",
		"isonet",
		false,
		9999,
		[]string{groupIDs[3]},
		nil,
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
	assert.NotContains(t, result, peerIDs[2])
	assert.NotContains(t, result, peerIDs[3])

	result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
	assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result)
	assert.NotContains(t, result, peerIDs[0])
	assert.NotContains(t, result, peerIDs[1])
}

func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	for _, g := range []*types.Group{
		{ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}},
		{ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}},
		{ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}},
	} {
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
	}

	_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{"ap-grpA"},
				Destinations:  []string{"ap-grpB"},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID})
	assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result)

	t.Run("group change updates all peers in policy groups", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldReceiveUpdate(t, updMsg2)
			peerShouldReceiveUpdate(t, updMsg3)
			close(done)
		}()

		err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{
			ID:    "ap-grpA",
			Name:  "AP-A",
			Peers: []string{peer1.ID, peer3.ID},
		})
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// A peer in no policy/route refreshes only itself — no other peer is affected.
func TestAffectedPeers_UnlinkedPeerChange_RefreshesSelfOnly(t *testing.T) {
	manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
	ctx := context.Background()

	result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
	assert.ElementsMatch(t, []string{peerIDs[0]}, result)
}

// TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a
// policy only sends updates to peers in the policy's groups, not to unrelated peers.
func TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	for _, g := range []*types.Group{
		{ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}},
		{ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}},
		{ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}},
	} {
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
	}

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	t.Run("create policy only affects linked peers", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldReceiveUpdate(t, updMsg2)
			peerShouldNotReceiveUpdate(t, updMsg3)
			close(done)
		}()

		_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
			Enabled: true,
			Rules: []*types.PolicyRule{
				{
					Enabled:       true,
					Sources:       []string{"pol-grpA"},
					Destinations:  []string{"pol-grpB"},
					Bidirectional: true,
					Action:        types.PolicyTrafficActionAccept,
				},
			},
		}, true)
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate verifies that creating a route
// only sends updates to peers in the route's groups, not to unrelated peers.
func TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	for _, g := range []*types.Group{
		{ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}},
		{ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}},
		{ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}},
	} {
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
	}

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	t.Run("create route only affects linked peers", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldReceiveUpdate(t, updMsg2)
			peerShouldNotReceiveUpdate(t, updMsg3)
			close(done)
		}()

		_, err := manager.CreateRoute(ctx, accountID,
			netip.MustParsePrefix("10.10.0.0/24"),
			route.IPv4Network,
			nil,
			"",
			[]string{"rt-grpA"},
			"test route",
			"routenoaffect",
			false,
			9999,
			[]string{"rt-grpB"},
			nil,
			true,
			userID,
			false,
			false,
		)
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate verifies that creating a
// nameserver group only sends updates to peers in its groups, not to unrelated peers.
func TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	for _, g := range []*types.Group{
		{ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}},
		{ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}},
	} {
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
	}

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	t.Run("create nameserver group only affects linked peers", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldNotReceiveUpdate(t, updMsg2)
			peerShouldNotReceiveUpdate(t, updMsg3)
			close(done)
		}()

		_, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated",
			[]nbdns.NameServer{{
				IP:     netip.MustParseAddr("1.1.1.1"),
				NSType: nbdns.UDPNameServerType,
				Port:   nbdns.DefaultDNSPort,
			}},
			[]string{"ns-grpA"},
			true, nil, true, userID, false,
		)
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate verifies that changing DNS
// settings only sends updates to peers in the affected groups, not to unrelated peers.
func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	for _, g := range []*types.Group{
		{ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}},
		{ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}},
	} {
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
	}

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	t.Run("dns settings change only affects linked peers", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldNotReceiveUpdate(t, updMsg2)
			peerShouldNotReceiveUpdate(t, updMsg3)
			close(done)
		}()

		err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{
			DisabledManagementGroups: []string{"dns-grpA"},
		})
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration tests the full integration:
// updating a group that is NOT referenced by any policy/route/ns/dns should not send
// updates to any peer.
func TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	err = manager.CreateGroup(ctx, accountID, userID, &types.Group{
		ID:    "unlinked-grp",
		Name:  "Unlinked",
		Peers: []string{peer1.ID},
	})
	require.NoError(t, err)

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	t.Run("updating unlinked group sends no peer updates", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldNotReceiveUpdate(t, updMsg1)
			peerShouldNotReceiveUpdate(t, updMsg2)
			peerShouldNotReceiveUpdate(t, updMsg3)
			close(done)
		}()

		err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{
			ID:    "unlinked-grp",
			Name:  "Unlinked",
			Peers: []string{peer1.ID, peer2.ID},
		})
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate: a network router with peer
// groups updates only those groups' peers (and resource policy sources), not others.
func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) {
	// Delete the default policy before adding peers so AddPeer schedules no async
	// update that races with the test.
	manager, updateManager, err := createManager(t)
	require.NoError(t, err)

	ctx := context.Background()

	account, err := createAccount(manager, "nr_test_account", userID, "")
	require.NoError(t, err)
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
	require.NoError(t, err)

	peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key)
	peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key)
	peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key)

	for _, g := range []*types.Group{
		{ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}},
		{ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}},
	} {
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
	}

	net1 := &networkTypes.Network{
		ID:        "nr-net-test",
		AccountID: accountID,
		Name:      "nr-test-network",
	}
	err = manager.Store.SaveNetwork(ctx, net1)
	require.NoError(t, err)

	err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
		ID:         "nr-router-test",
		NetworkID:  net1.ID,
		AccountID:  accountID,
		PeerGroups: []string{"nr-grpA"},
		Enabled:    true,
	})
	require.NoError(t, err)

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	t.Run("network router group change only affects linked peers", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldNotReceiveUpdate(t, updMsg2)
			peerShouldReceiveUpdate(t, updMsg3)
			close(done)
		}()

		err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{
			ID:    "nr-grpA",
			Name:  "NR-A",
			Peers: []string{peer1.ID, peer3.ID},
		})
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers: with a policy (peer1<->peer2)
// and a separate route (peer3), changing one entity's groups affects only its peers.
func TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	for _, g := range []*types.Group{
		{ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}},
		{ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}},
		{ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}},
	} {
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
	}

	_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{"iso-grpA"},
				Destinations:  []string{"iso-grpB"},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	_, err = manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.20.0.0/24"),
		route.IPv4Network,
		nil,
		"",
		[]string{"iso-grpC"},
		"isolated route",
		"isonet2",
		false,
		9999,
		[]string{"iso-grpC"},
		nil,
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	// The setup policy/route above dispatch affected-peer updates asynchronously;
	// drain any in-flight ones so the assertions only observe the UpdateGroup below.
	settleAffectedUpdates(updMsg1, updMsg2, updMsg3)

	t.Run("policy group change does not affect route-only peer", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldReceiveUpdate(t, updMsg2)
			peerShouldNotReceiveUpdate(t, updMsg3)
			close(done)
		}()

		err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{
			ID:    "iso-grpA",
			Name:  "ISO-A-updated",
			Peers: []string{peer1.ID},
		})
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate verifies that deleting a route
// only sends updates to peers in the route's groups.
func TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	for _, g := range []*types.Group{
		{ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}},
		{ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}},
	} {
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
	}

	newRoute, err := manager.CreateRoute(ctx, accountID,
		netip.MustParsePrefix("10.30.0.0/24"),
		route.IPv4Network,
		nil,
		"",
		[]string{"del-rt-grpA"},
		"deletable route",
		"delnet",
		false,
		9999,
		[]string{"del-rt-grpB"},
		nil,
		true,
		userID,
		false,
		false,
	)
	require.NoError(t, err)

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	t.Run("delete route only affects linked peers", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldReceiveUpdate(t, updMsg2)
			peerShouldNotReceiveUpdate(t, updMsg3)
			close(done)
		}()

		err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID)
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate verifies that deleting a policy
// only sends updates to peers in the policy's groups.
func TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	for _, g := range []*types.Group{
		{ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}},
		{ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}},
	} {
		err := manager.CreateGroup(ctx, accountID, userID, g)
		require.NoError(t, err)
	}

	policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
		Enabled: true,
		Rules: []*types.PolicyRule{
			{
				Enabled:       true,
				Sources:       []string{"del-pol-grpA"},
				Destinations:  []string{"del-pol-grpB"},
				Bidirectional: true,
				Action:        types.PolicyTrafficActionAccept,
			},
		},
	}, true)
	require.NoError(t, err)

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	t.Run("delete policy only affects linked peers", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldReceiveUpdate(t, updMsg2)
			peerShouldNotReceiveUpdate(t, updMsg3)
			close(done)
		}()

		err := manager.DeletePolicy(ctx, accountID, policy.ID, userID)
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

// TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate verifies that deleting a
// nameserver group only sends updates to peers in its groups.
func TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate(t *testing.T) {
	manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
	ctx := context.Background()
	accountID := account.Id

	policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
	require.NoError(t, err)
	for _, p := range policies {
		err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
		require.NoError(t, err)
	}

	err = manager.CreateGroup(ctx, accountID, userID, &types.Group{
		ID:    "del-ns-grpA",
		Name:  "Del-NS-A",
		Peers: []string{peer1.ID},
	})
	require.NoError(t, err)

	nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS",
		[]nbdns.NameServer{{
			IP:     netip.MustParseAddr("8.8.4.4"),
			NSType: nbdns.UDPNameServerType,
			Port:   nbdns.DefaultDNSPort,
		}},
		[]string{"del-ns-grpA"},
		true, nil, true, userID, false,
	)
	require.NoError(t, err)

	updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
	updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
	updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
	t.Cleanup(func() {
		updateManager.CloseChannel(ctx, peer1.ID)
		updateManager.CloseChannel(ctx, peer2.ID)
		updateManager.CloseChannel(ctx, peer3.ID)
	})

	t.Run("delete nameserver group only affects linked peers", func(t *testing.T) {
		done := make(chan struct{})
		go func() {
			peerShouldReceiveUpdate(t, updMsg1)
			peerShouldNotReceiveUpdate(t, updMsg2)
			peerShouldNotReceiveUpdate(t, updMsg3)
			close(done)
		}()

		err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID)
		assert.NoError(t, err)

		select {
		case <-done:
		case <-time.After(peerUpdateTimeout):
			t.Error("timeout")
		}
	})
}

func addPeerToAccount(t *testing.T, manager *DefaultAccountManager, _, setupKeyKey string) *nbpeer.Peer {
	t.Helper()

	key, err := wgtypes.GeneratePrivateKey()
	require.NoError(t, err)

	peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{
		Key:  key.PublicKey().String(),
		Meta: nbpeer.PeerSystemMeta{Hostname: key.PublicKey().String()},
	}, false)
	require.NoError(t, err)
	return peer
}

// markPeerAsProxy flips an existing peer's ProxyMeta to mark it as an embedded
// proxy peer in the given cluster.
func markPeerAsProxy(t *testing.T, s store.Store, accountID, peerID, cluster string) {
	t.Helper()
	ctx := context.Background()
	peer, err := s.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
	require.NoError(t, err)
	peer.ProxyMeta = nbpeer.ProxyMeta{Embedded: true, Cluster: cluster}
	require.NoError(t, s.SavePeer(ctx, accountID, peer))
}

// createServiceWithTargets persists a service with the given cluster and targets
// directly in the store, bypassing the proxy-service manager (which would also
// run cluster derivation and trigger UpdateAccountPeers).
func createServiceWithTargets(t *testing.T, s store.Store, accountID, cluster string, targets []*rpservice.Target) *rpservice.Service {
	t.Helper()
	svc := &rpservice.Service{
		AccountID:    accountID,
		Name:         fmt.Sprintf("svc-%s", cluster),
		Domain:       fmt.Sprintf("%s.example.com", cluster),
		ProxyCluster: cluster,
		Enabled:      true,
		Mode:         "tcp",
		Targets:      targets,
	}
	svc.InitNewRecord()
	for _, target := range targets {
		target.AccountID = accountID
		target.ServiceID = svc.ID
	}
	require.NoError(t, s.CreateService(context.Background(), svc))
	return svc
}

func TestCollectAffectedFromProxyServices_TargetPeerChanged(t *testing.T) {
	manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
	ctx := context.Background()

	cluster := "cluster-a"
	markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)

	createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
		{TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"},
	})

	_, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]})
	assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when its target peer changes")
	assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed")
}

func TestCollectAffectedFromProxyServices_ProxyPeerChanged(t *testing.T) {
	manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
	ctx := context.Background()

	cluster := "cluster-a"
	markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)

	createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
		{TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"},
		{TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[2], Enabled: true, Port: 80, Protocol: "tcp"},
	})

	_, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]})
	assert.Contains(t, directPeers, peerIDs[0], "changed proxy peer is itself refreshed")
	assert.Contains(t, directPeers, peerIDs[1], "target peer 1 must be refreshed when proxy peer changes")
	assert.Contains(t, directPeers, peerIDs[2], "target peer 2 must be refreshed when proxy peer changes")
}

func TestCollectAffectedFromProxyServices_GroupContainingTargetPeerChanged(t *testing.T) {
	manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
	ctx := context.Background()

	cluster := "cluster-a"
	markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)

	createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
		{TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"},
	})

	_, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, []string{groupIDs[1]}, nil)
	assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when a group containing its target peer changes")
	assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed")
}

// A disabled service in the snapshot proxies nothing, so it is skipped: a changed
// target peer does not pull in the service's proxy peer.
func TestCollectAffectedFromProxyServices_DisabledServiceSkipped(t *testing.T) {
	manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
	ctx := context.Background()

	cluster := "cluster-a"
	markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)

	svc := &rpservice.Service{
		AccountID:    accountID,
		Name:         "disabled-svc",
		Domain:       "disabled.example.com",
		ProxyCluster: cluster,
		Enabled:      false,
		Mode:         "tcp",
		Targets: []*rpservice.Target{
			{TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: false, Port: 80, Protocol: "tcp"},
		},
	}
	svc.InitNewRecord()
	for _, target := range svc.Targets {
		target.AccountID = accountID
		target.ServiceID = svc.ID
	}
	require.NoError(t, s.CreateService(ctx, svc))

	_, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]})
	assert.NotContains(t, directPeers, peerIDs[0], "a disabled service proxies nothing, so its proxy peer must not be folded")
}

func TestCollectAffectedFromProxyServices_NonPeerTargetType(t *testing.T) {
	manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
	ctx := context.Background()

	cluster := "cluster-a"
	markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)

	createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
		{TargetType: rpservice.TargetTypeHost, TargetId: "10.0.0.1", Host: "10.0.0.1", Enabled: true, Port: 80, Protocol: "tcp"},
	})

	_, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]})
	assert.Contains(t, directPeers, peerIDs[0], "host target service still refreshes its proxy peer when the proxy peer changes")
	assert.NotContains(t, directPeers, "10.0.0.1", "non-peer target ids must not appear as affected peer IDs")
}
