// Package affectedpeers computes which peers' network maps a change touches, so
// only those peers are refreshed instead of the whole account.
//
// Two phases keep the dependency walk off the write transaction:
//   - Load: reads the needed collections. Call INSIDE the mutating tx (consistent,
//     and before a delete/removal severs the old state).
//   - Snapshot.Expand: in-memory walk, no store access. Run AFTER the tx commits.
//
// Enabled handling differs by source. Disabled objects in the SNAPSHOT (existing
// account policies/resources/routers/routes/proxy services and their rules/targets)
// route to nobody and are skipped — they cannot affect any peer's map. Objects in
// the CHANGE itself are processed regardless of Enabled, so disabling one still
// refreshes the peers that lose access (the toggle is the observable change, and the
// update carries the old∪new state).
package affectedpeers

import (
	"context"

	log "github.com/sirupsen/logrus"
	"golang.org/x/exp/maps"

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

// agentNetworkSynthesizer returns the account's synthesised (never-persisted)
// agent-network reverse-proxy services. It is registered at boot via
// SetAgentNetworkSynthesizer to avoid an import cycle (agentnetwork → account →
// affectedpeers). nil when agent-network is not wired, in which case only
// persisted services are considered.
var agentNetworkSynthesizer func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error)

// SetAgentNetworkSynthesizer registers the agent-network service synthesiser.
// Called once during boot, before any request is served.
func SetAgentNetworkSynthesizer(fn func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error)) {
	agentNetworkSynthesizer = fn
}

// Snapshot is an in-memory view of the collections needed to expand a Change.
// Loaded in-tx, walked by Expand after commit. Only the collections the Change
// can touch are loaded; the rest stay nil (see Load).
type Snapshot struct {
	policies       []*types.Policy
	routes         []*route.Route
	nsGroups       []*nbdns.NameServerGroup
	dnsSettings    *types.DNSSettings
	routers        []*routerTypes.NetworkRouter
	resources      []*resourceTypes.NetworkResource
	services       []*rpservice.Service
	proxyByCluster map[string][]string
	groups         map[string]*types.Group
	groupPeers     map[string]map[string]struct{} // groupID -> member peer IDs
}

// Load reads the collections a Change requires, inside the caller's tx. It mirrors
// Expand's walker preconditions, loading only what the change can touch.
func Load(ctx context.Context, s store.Store, accountID string, c Change) (*Snapshot, error) {
	snap := &Snapshot{}
	if c.isEmpty() {
		return snap, nil
	}

	if err := snap.loadCollections(ctx, s, accountID, c); err != nil {
		return nil, err
	}
	if err := snap.loadGroupIndex(ctx, s, accountID); err != nil {
		return nil, err
	}

	return snap, nil
}

// loadCollections reads the policy/route/nameserver/dns/router/resource/proxy
// collections a Change can touch, gated to what the walk needs.
func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accountID string, c Change) error {
	// LinkGroups drive the same policy/route/dns walk as a changed group or peer.
	hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 || len(c.Resources) > 0
	hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0
	// the resource<->router bridge can fire for any of these
	needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject || len(c.UserGroupIDs) > 0 || c.AllowedUsersChanged

	if needsRoutersResources {
		if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil {
			return err
		}
	}
	if hasGroupOrPeerChange {
		if err := snap.loadRoutesAndProxy(ctx, s, accountID); err != nil {
			return err
		}
	}
	if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 {
		if err := snap.loadDNS(ctx, s, accountID); err != nil {
			return err
		}
	}
	return nil
}

// loadPolicyRoutersResources loads the policies plus the routers and resources
// the resource<->router bridge walks.
func (snap *Snapshot) loadPolicyRoutersResources(ctx context.Context, s store.Store, accountID string) error {
	var err error
	if snap.policies, err = s.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID); err != nil {
		return err
	}
	if snap.routers, err = s.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID); err != nil {
		return err
	}
	snap.resources, err = s.GetNetworkResourcesByAccountID(ctx, store.LockingStrengthNone, accountID)
	return err
}

// loadRoutesAndProxy loads the routes and the embedded-proxy services index.
func (snap *Snapshot) loadRoutesAndProxy(ctx context.Context, s store.Store, accountID string) error {
	var err error
	if snap.routes, err = s.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID); err != nil {
		return err
	}
	return snap.loadProxyServices(ctx, s, accountID)
}

// loadDNS loads the nameserver groups and account DNS settings.
func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID string) error {
	var err error
	if snap.nsGroups, err = s.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID); err != nil {
		return err
	}
	snap.dnsSettings, err = s.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID)
	return err
}

// loadProxyServices loads the embedded-proxy cluster index, and the services only
// when the account actually has embedded proxy peers. Both the persisted
// reverse-proxy services and the synthesised agent-network services are loaded:
// agent-network services are never persisted, so without synthesising them here
// collectFromProxyServices can't fold the embedded proxy peer into the affected
// set when a client's group changes, and the proxy never learns a newly
// authorised client until it reconnects (full network-map resync).
func (snap *Snapshot) loadProxyServices(ctx context.Context, s store.Store, accountID string) error {
	var err error
	if snap.proxyByCluster, err = s.GetEmbeddedProxyPeerIDsByCluster(ctx, accountID); err != nil {
		return err
	}
	if len(snap.proxyByCluster) == 0 {
		return nil
	}
	if snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID); err != nil {
		return err
	}
	if agentNetworkSynthesizer == nil {
		return nil
	}
	synth, serr := agentNetworkSynthesizer(ctx, s, accountID)
	if serr != nil {
		// Non-fatal: fall back to persisted services. The next full
		// network-map resync still converges the proxy.
		log.WithContext(ctx).Warnf("affectedpeers: synthesise agent-network services for account %s: %v", accountID, serr)
		return nil
	}
	snap.services = append(snap.services, synth...)
	return nil
}

// loadGroupIndex loads all groups (for group.Resources) and builds the
// group->member-peers index. Always needed: the bridge resolves group.Resources
// and Expand maps groups to member peers.
func (snap *Snapshot) loadGroupIndex(ctx context.Context, s store.Store, accountID string) error {
	groups, err := s.GetAccountGroups(ctx, store.LockingStrengthNone, accountID)
	if err != nil {
		return err
	}
	snap.groups = make(map[string]*types.Group, len(groups))
	snap.groupPeers = make(map[string]map[string]struct{}, len(groups))
	for _, g := range groups {
		snap.groups[g.ID] = g
		members := make(map[string]struct{}, len(g.Peers))
		for _, pID := range g.Peers {
			members[pID] = struct{}{}
		}
		snap.groupPeers[g.ID] = members
	}
	return nil
}

// Change describes what changed in an account.
type Change struct {
	ChangedGroupIDs []string
	ChangedPeerIDs  []string
	Policies        []*types.Policy
	Routes          []*route.Route
	Routers         []*routerTypes.NetworkRouter
	Resources       []*resourceTypes.NetworkResource
	Networks        []*networkTypes.Network
	PostureCheckIDs []string

	// DistributionGroupIDs are groups whose members are directly affected, with no
	// dependency walk — the change distributes config to the groups' member peers
	// only (nameserver groups, DNS DisabledManagementGroups), not through the
	// policy/route reachability graph. Pass old∪new so both states refresh.
	DistributionGroupIDs []string

	// RemovedPeersByGroup: peers that left a group, keyed by that group. They are no
	// longer in the group's member index but still lose its reachability, so they are
	// folded in — but only when the group is linked (an unlinked group has no map
	// impact), matching how current members are handled.
	RemovedPeersByGroup map[string][]string

	// OutputPeerIDs are peers folded straight into the result without seeding their
	// group memberships into the walk. Use for the peer whose group membership changed:
	// the peer itself must refresh, but its OTHER groups did not change, so they must
	// not be walked. Contrast ChangedPeerIDs, which seeds ALL of the peer's groups
	// (correct when the peer's own attributes changed, e.g. IP/status).
	OutputPeerIDs []string

	// UserGroupIDs are groups whose USER membership changed (a user's auto-groups),
	// as opposed to their peer membership. Peers ship the group -> user mapping only
	// for the groups an SSH rule authorizes, so these refresh the destinations of the
	// SSH rules authorizing them — independently of any peer moving between groups.
	UserGroupIDs []string

	// AllowedUsersChanged marks a change to the set of users allowed to open SSH
	// sessions — a user was created, blocked or unblocked. That set is account-wide,
	// and peers receive it through the SSH rules that name no group or user of their
	// own, so those rules' destinations refresh.
	AllowedUsersChanged bool

	// LinkGroups are groups used ONLY to match policies/routes/routers and walk to the
	// OPPOSITE side — they are never expanded to their own members. Use this when a
	// peer's group membership changed: pass the peer in ChangedPeerIDs and its
	// group(s) here. The opposite side of the policies the group participates in
	// refreshes, but the group's other members (siblings) do not — nothing changed for
	// them. For an intra-group policy (A→A) the opposite side IS the group, so its
	// members still refresh via the opposite-side fold, exactly when they genuinely
	// gain/lose the changed peer. Unlike ChangedGroupIDs, a LinkGroup is not added to
	// the output, so a one-sided membership change never wakes the whole group.
	LinkGroups []string
}

func (c Change) isEmpty() bool {
	return len(c.ChangedGroupIDs) == 0 &&
		len(c.ChangedPeerIDs) == 0 &&
		len(c.Policies) == 0 &&
		len(c.Routes) == 0 &&
		len(c.Routers) == 0 &&
		len(c.Resources) == 0 &&
		len(c.Networks) == 0 &&
		len(c.PostureCheckIDs) == 0 &&
		len(c.UserGroupIDs) == 0 &&
		!c.AllowedUsersChanged &&
		len(c.DistributionGroupIDs) == 0 &&
		len(c.RemovedPeersByGroup) == 0 &&
		len(c.LinkGroups) == 0 &&
		len(c.OutputPeerIDs) == 0
}

// Expand returns the deduplicated affected peer IDs from the preloaded Snapshot,
// no store access. Run after the producing tx commits. Logs the full walk at
// trace level for diagnosing a miscalculation.
func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []string {
	if c.isEmpty() {
		return nil
	}
	r := newResolver(ctx, snap, accountID, c)
	log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v linkGroups=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v",
		accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, c.LinkGroups, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs)
	r.walk()
	return r.expand()
}

// Collect returns the affected group and direct-peer IDs without expanding groups
// to members. Test-only introspection; use Resolve otherwise.
func Collect(ctx context.Context, s store.Store, accountID string, c Change) (groupIDs []string, directPeerIDs []string) {
	if c.isEmpty() {
		return nil, nil
	}
	snap, err := Load(ctx, s, accountID, c)
	if err != nil {
		log.WithContext(ctx).Errorf("failed to load snapshot for affected peers collect: %v", err)
		return nil, nil
	}
	r := newResolver(ctx, snap, accountID, c)
	r.walk()
	return setToSlice(r.affectedGroups), setToSlice(r.affectedPeers)
}

func newResolver(ctx context.Context, snap *Snapshot, accountID string, c Change) *resolver {
	r := &resolver{
		ctx:            ctx,
		snap:           snap,
		accountID:      accountID,
		change:         c,
		linkGroups:     toSet(c.ChangedGroupIDs),
		outputGroups:   toSet(c.ChangedGroupIDs),
		changedPeers:   toSet(c.ChangedPeerIDs),
		affectedGroups: make(map[string]struct{}),
		affectedPeers:  make(map[string]struct{}),
	}
	// LinkGroups match policies/routes to find the opposite side but are NOT output:
	// they go into linkGroups only, never outputGroups, so their members never fold in.
	addAll(r.linkGroups, c.LinkGroups)
	// Resolve each changed peer to its groups here so callers pass only ChangedPeerIDs.
	r.seedChangedGroupsFromPeers()
	return r
}

// seedChangedGroupsFromPeers adds each changed peer's groups to linkGroups so
// the group-driven walkers fire for memberships, not just direct peer references.
// These seeded groups are for MATCHING only — folding the changed entity's own
// side is gated on outputGroups (the caller-reported groups), so a seeded group
// never folds its whole membership; only the changed peer itself folds in.
func (r *resolver) seedChangedGroupsFromPeers() {
	if len(r.changedPeers) == 0 {
		return
	}
	for groupID, members := range r.snap.groupPeers {
		for pID := range r.changedPeers {
			if _, ok := members[pID]; ok {
				r.linkGroups[groupID] = struct{}{}
				break
			}
		}
	}
}

// policySide selects which side of a policy rule to walk.
type policySide int

const (
	sideSource policySide = iota
	sideDestination
)

func (s policySide) opposite() policySide {
	if s == sideSource {
		return sideDestination
	}
	return sideSource
}

// walk resolves affected peers in two buckets, by how far each change propagates.
//
// BOTH-SIDES — the rule itself changed (an explicit policy edit, or a policy whose
// posture check changed). Source AND destination refresh, so each such policy is
// walked on both sides.
//
// OPPOSITE-SIDE — an endpoint moved but no rule changed. For each policy the change
// touches we fold only the side AWAY from the change:
//   - a changed peer/group sits ON a policy side -> fold the opposite side;
//   - a changed router/resource/network sits on a NETWORK -> fold the SOURCE side of
//     the policies whose destination reaches it (and the routers it implies).
//
// Routes, nameserver groups, DNS and embedded-proxy services distribute to their own
// member peers, outside the policy graph, and are folded here too.
func (r *resolver) walk() {
	for _, policy := range r.bothSidesPolicies() {
		r.foldPolicySide(policy, sideSource)
		r.foldPolicySide(policy, sideDestination)
	}

	if len(r.linkGroups) > 0 || len(r.changedPeers) > 0 {
		r.collectFromPolicies()
		r.collectFromRoutes()
		r.collectFromNameServers()
		r.collectFromDNSSettings()
		r.collectFromNetworkRouters()
		r.collectFromProxyServices()
	}

	r.collectFromSSHAuthorizedGroups()
	r.collectFromAllowedUsers()

	r.collectFromChangedRoutes(r.change.Routes)
	r.collectFromChangedRouters(r.change.Routers)
	r.collectFromChangedResources(r.change.Resources)
	r.collectFromChangedNetworks(r.change.Networks)

	// The explicitly changed peers always refresh their own maps. OnPeersUpdated only
	// refreshes the resolver's output (it ignores the separately-passed changed peers),
	// so the changed peer reaches its own new map only via here. An offline/deleted
	// peer in the set is filtered downstream (filterConnectedAffectedPeers).
	addAll(r.affectedPeers, setToSlice(r.changedPeers))
	// OutputPeerIDs refresh themselves too, but unlike changedPeers their group
	// memberships were not seeded into the walk (only the changed group was).
	addAll(r.affectedPeers, r.change.OutputPeerIDs)

	// Distribution groups (nameserver/DNS) affect only their member peers: fold them
	// straight into affectedGroups so expand() maps them to members, without the
	// policy/route walk that linkGroups would trigger.
	addAll(r.affectedGroups, r.change.DistributionGroupIDs)
}

// bothSidesPolicies are the policies whose rule changed: the explicitly edited ones
// plus those gated by a changed posture check. walk folds both their sides.
func (r *resolver) bothSidesPolicies() []*types.Policy {
	policies := append([]*types.Policy(nil), r.change.Policies...)
	return r.appendPoliciesForPostureChecks(policies, r.change.PostureCheckIDs)
}

type resolver struct {
	ctx       context.Context
	snap      *Snapshot
	accountID string
	change    Change

	// Inputs — what changed. Set once at construction, read-only during the walk
	// (except linkGroups, which collectFromExplicitResources also seeds).
	//
	// linkGroups is the MATCH set: caller-changed groups ∪ the groups of changed
	// peers ∪ changed-resource groups. A rule/route/router matches the change when
	// one of its groups is here — used only to find the opposite side to fold.
	//
	// outputGroups is the FOLD-WHOLE-GROUP set: ONLY Change.ChangedGroupIDs. When a
	// matched group is here, its whole membership is affected. A peer-seeded group
	// is in linkGroups but NOT outputGroups, so it folds only the changed peer
	// (changedPeers), never its siblings.
	linkGroups   map[string]struct{}
	outputGroups map[string]struct{}
	changedPeers map[string]struct{}

	// Outputs — the answer. The only sets the walk accumulates into. affectedGroups
	// is expanded to its member peers in expand().
	affectedGroups map[string]struct{}
	affectedPeers  map[string]struct{}
}

// policies returns the account's ENABLED policies from the snapshot. Disabled
// policies grant no access, so the walk skips them when scanning existing account
// data. Explicitly changed policies (Change.Policies, via bothSidesPolicies) are
// processed regardless of Enabled, so disabling one still refreshes its peers.
func (r *resolver) policies() []*types.Policy {
	enabled := make([]*types.Policy, 0, len(r.snap.policies))
	for _, policy := range r.snap.policies {
		if policy != nil && policy.Enabled {
			enabled = append(enabled, policy)
		}
	}
	return enabled
}

// networkResources / networkRouters return the account's ENABLED resources/routers
// from the snapshot. Disabled objects route to nobody, so the walk skips them when
// it scans existing account data. The explicitly changed objects in the Change are
// processed regardless of Enabled (collectFromChanged*), so disabling one still
// refreshes the peers that lose access.
func (r *resolver) networkResources() []*resourceTypes.NetworkResource {
	enabled := make([]*resourceTypes.NetworkResource, 0, len(r.snap.resources))
	for _, resource := range r.snap.resources {
		if resource.Enabled {
			enabled = append(enabled, resource)
		}
	}
	return enabled
}

func (r *resolver) networkRouters() []*routerTypes.NetworkRouter {
	enabled := make([]*routerTypes.NetworkRouter, 0, len(r.snap.routers))
	for _, router := range r.snap.routers {
		if router.Enabled {
			enabled = append(enabled, router)
		}
	}
	return enabled
}

// peerIDsForGroups maps a group set to its member peer IDs via the preloaded index.
func (r *resolver) peerIDsForGroups(groups map[string]struct{}) []string {
	seen := make(map[string]struct{})
	var ids []string
	for gID := range groups {
		for pID := range r.snap.groupPeers[gID] {
			if _, ok := seen[pID]; ok {
				continue
			}
			seen[pID] = struct{}{}
			ids = append(ids, pID)
		}
	}
	return ids
}

func (r *resolver) expand() []string {
	peerIDs := r.peerIDsForGroups(r.affectedGroups)

	log.WithContext(r.ctx).Tracef("affectedpeers expand: account=%s affectedGroups=%v -> %d group-member peers; direct peers=%v",
		r.accountID, setToSlice(r.affectedGroups), len(peerIDs), setToSlice(r.affectedPeers))

	seen := make(map[string]struct{}, len(peerIDs))
	for _, id := range peerIDs {
		seen[id] = struct{}{}
	}
	for id := range r.affectedPeers {
		if _, ok := seen[id]; !ok {
			peerIDs = append(peerIDs, id)
			seen[id] = struct{}{}
		}
	}

	// Fold in removed peers only when their group is linked (in affectedGroups).
	for groupID, removed := range r.change.RemovedPeersByGroup {
		if _, linked := r.affectedGroups[groupID]; !linked {
			continue
		}
		for _, id := range removed {
			if _, ok := seen[id]; !ok {
				peerIDs = append(peerIDs, id)
				seen[id] = struct{}{}
				log.WithContext(r.ctx).Tracef("affectedpeers expand: removed peer %s from linked group %s -> affected", id, groupID)
			}
		}
	}

	log.WithContext(r.ctx).Tracef("affectedpeers expand done: account=%s -> %d affected peers: %v", r.accountID, len(peerIDs), peerIDs)
	return peerIDs
}

// ruleSideGroups / ruleSideResource return the groups and the resource on the given
// side of a rule.
func ruleSideGroups(rule *types.PolicyRule, side policySide) []string {
	if side == sideDestination {
		return rule.Destinations
	}
	return rule.Sources
}

func ruleSideResource(rule *types.PolicyRule, side policySide) types.Resource {
	if side == sideDestination {
		return rule.DestinationResource
	}
	return rule.SourceResource
}

// foldPolicySide folds one side of a policy down to affected peers: its groups
// (resolved to members in expand) and its direct peer. When the side is the
// DESTINATION and references a network resource (directly or via a destination
// group's resources), it also folds the routers that serve that resource's network
// — a destination resource is reached through its routers. A resource on the SOURCE
// side routes to nobody (GetPoliciesForNetworkResource matches destinations only),
// so the router hop is destination-only.
func (r *resolver) foldPolicySide(policy *types.Policy, side policySide) {
	if policy == nil {
		return
	}
	for _, rule := range policy.Rules {
		addAll(r.affectedGroups, ruleSideGroups(rule, side))
		res := ruleSideResource(rule, side)
		if res.Type == types.ResourceTypePeer && res.ID != "" {
			r.affectedPeers[res.ID] = struct{}{}
		}
	}
	if side == sideDestination {
		r.foldRoutersForResources(r.policyDestinationResourceIDs(policy))
	}
}

// appendPoliciesForPostureChecks appends every policy that references a changed
// posture check (a rule change, so walk both sides).
func (r *resolver) appendPoliciesForPostureChecks(policies []*types.Policy, postureCheckIDs []string) []*types.Policy {
	if len(postureCheckIDs) == 0 {
		return policies
	}
	ids := toSet(postureCheckIDs)
	for _, policy := range r.policies() {
		if !policyReferencesPostureChecks(policy, ids) || !policy.Enabled {
			continue
		}
		log.WithContext(r.ctx).Tracef("appendPoliciesForPostureChecks: policy %s (%s) references changed posture checks %v -> both-sides policy",
			policy.ID, policy.Name, postureCheckIDs)
		policies = append(policies, policy)
	}
	return policies
}

// collectFromPolicies folds, for every policy whose rule a changed group or peer
// touches, only the OPPOSITE side (down to peers, incl. destination routers), plus
// the changed entity's own side: the changed group's whole membership when the
// group itself changed (outputGroups), or the changed peer alone when matched via a
// peer-seeded group (never its co-members).
func (r *resolver) collectFromPolicies() {
	for _, policy := range r.policies() {
		for _, rule := range policy.Rules {
			if !rule.Enabled {
				continue // a disabled rule grants no access
			}
			r.foldRuleSideIfChanged(policy, rule, sideSource)
			r.foldRuleSideIfChanged(policy, rule, sideDestination)
		}
	}
}

// foldRuleSideIfChanged: when a changed group or direct peer sits on `side` of the
// rule, fold the opposite side fully (groups/peers + destination routers) and fold
// the changed entity's own side (the whole changed group, or the changed peer alone).
func (r *resolver) foldRuleSideIfChanged(policy *types.Policy, rule *types.PolicyRule, side policySide) {
	nearGroups := ruleSideGroups(rule, side)
	nearResource := ruleSideResource(rule, side)

	matchedByGroup := anyInSet(nearGroups, r.linkGroups)
	matchedByPeer := isDirectPeerInSet(nearResource, r.changedPeers)
	if !matchedByGroup && !matchedByPeer {
		return
	}

	// Opposite side, fully down to peers (a destination opposite also folds routers).
	r.foldPolicySideForRule(policy, rule, side.opposite())

	// Own side: fold the whole changed group's members only when the group itself
	// changed (outputGroups). A peer-seeded or link-only group is not folded here —
	// its siblings never refresh. The changed peers themselves are folded once, after
	// the walk (see walk()).
	for _, gID := range nearGroups {
		if _, ok := r.outputGroups[gID]; ok {
			r.affectedGroups[gID] = struct{}{}
		}
	}

	// When the changed side IS a destination, the resources it targets are reached
	// through their network's routers, so those routers refresh too (e.g. attaching a
	// resource to a destination group, or a changed destination group/resource).
	if side == sideDestination {
		r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule))
	}
}

// foldPolicySideForRule folds one side of a single rule (groups + direct peer), and
// for a destination side the routers of that rule's destination resources.
func (r *resolver) foldPolicySideForRule(policy *types.Policy, rule *types.PolicyRule, side policySide) {
	addAll(r.affectedGroups, ruleSideGroups(rule, side))
	res := ruleSideResource(rule, side)
	if res.Type == types.ResourceTypePeer && res.ID != "" {
		r.affectedPeers[res.ID] = struct{}{}
	}
	if side == sideDestination {
		r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule))
	}
}

// collectFromChangedRoutes folds an explicitly changed route's own groups and peer.
func (r *resolver) collectFromChangedRoutes(routes []*route.Route) {
	for _, rt := range routes {
		if rt == nil {
			continue
		}
		log.WithContext(r.ctx).Tracef("collectFromChangedRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q",
			rt.ID, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer)
		addAll(r.affectedGroups, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
		if rt.Peer != "" {
			r.affectedPeers[rt.Peer] = struct{}{}
		}
	}
}

// collectFromChangedRouters: a changed router refreshes its OWN backing peer/groups
// (the changed entity) and the SOURCE side of every policy reaching a resource on
// its network (the router serves the whole network). Sibling routers on the network
// are independent and are NOT folded. Passing the old router state keeps a repointed
// router's previous backing affected without a post-commit read.
func (r *resolver) collectFromChangedRouters(routers []*routerTypes.NetworkRouter) {
	for _, router := range routers {
		if router == nil {
			continue
		}
		log.WithContext(r.ctx).Tracef("collectFromChangedRouters: changed router %s on network %s -> folding its own peerGroups=%v peer=%q + sources reaching network resources",
			router.ID, router.NetworkID, router.PeerGroups, router.Peer)
		addAll(r.affectedGroups, router.PeerGroups)
		if router.Peer != "" {
			r.affectedPeers[router.Peer] = struct{}{}
		}
		if router.NetworkID != "" {
			r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID))
		}
	}
}

// collectFromChangedResources: a changed resource refreshes the SOURCE side of the
// policies targeting EXACTLY that resource — directly, or via one of the resource's
// own groups (old∪new across the change, so a now-detached group's sources still
// refresh) — plus the routers serving its network (the resource is reached through
// them). It does not touch sibling resources on the same network.
func (r *resolver) collectFromChangedResources(resources []*resourceTypes.NetworkResource) {
	for _, resource := range resources {
		if resource == nil {
			continue
		}
		log.WithContext(r.ctx).Tracef("collectFromChangedResources: changed resource %s on network %s (groups %v) -> folding sources of policies targeting it + its network's routers",
			resource.ID, resource.NetworkID, resource.GroupIDs)
		r.foldPolicySourcesForResource(resource.ID, resource.GroupIDs)
		if resource.NetworkID != "" {
			r.foldRoutersOnNetworks(map[string]struct{}{resource.NetworkID: {}})
		}
	}
}

// foldPolicySourcesForResource folds the source side of every policy whose
// destination is the given resource — referenced directly, or via any of the given
// groups (the resource's own old∪new groups, which captures a detached group).
func (r *resolver) foldPolicySourcesForResource(resourceID string, groupIDs []string) {
	groups := toSet(groupIDs)
	for _, policy := range r.policies() {
		if !policyTargetsResourceOrGroups(policy, resourceID, groups) {
			continue
		}
		log.WithContext(r.ctx).Tracef("foldPolicySourcesForResource: policy %s (%s) targets changed resource %s -> folding its source groups/peers", policy.ID, policy.Name, resourceID)
		collectPolicySources(policy, r.affectedGroups, r.affectedPeers)
	}
}

// policyTargetsResourceOrGroups reports whether a policy's destination is the given
// resource directly, or one of the given destination groups.
func policyTargetsResourceOrGroups(policy *types.Policy, resourceID string, groups map[string]struct{}) bool {
	if policy == nil {
		return false
	}
	for _, rule := range policy.Rules {
		if !rule.Enabled {
			continue
		}
		if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID == resourceID && resourceID != "" {
			return true
		}
		if anyInSet(rule.Destinations, groups) {
			return true
		}
	}
	return false
}

// collectFromChangedNetworks: a changed network refreshes the SOURCE side of the
// policies reaching any of its resources, plus its routers. A network has no
// groups/peers of its own.
func (r *resolver) collectFromChangedNetworks(networks []*networkTypes.Network) {
	for _, network := range networks {
		if network == nil || network.ID == "" {
			continue
		}
		log.WithContext(r.ctx).Tracef("collectFromChangedNetworks: changed network %s -> folding sources reaching its resources + its routers", network.ID)
		resourceIDs := r.networkResourceIDs(network.ID)
		r.foldPolicySourcesForResources(resourceIDs)
		r.foldRoutersOnNetworks(map[string]struct{}{network.ID: {}})
	}
}

// foldPolicySourcesForResources folds the source groups/peers of every policy whose
// destination targets one of resourceIDs (directly or via a destination group).
func (r *resolver) foldPolicySourcesForResources(resourceIDs map[string]struct{}) {
	if len(resourceIDs) == 0 {
		return
	}
	for _, policy := range r.policies() {
		if r.policyTargetsResources(policy, resourceIDs) {
			log.WithContext(r.ctx).Tracef("foldPolicySourcesForResources: policy %s (%s) targets a changed resource -> folding its source groups/peers", policy.ID, policy.Name)
			collectPolicySources(policy, r.affectedGroups, r.affectedPeers)
		}
	}
}

// collectFromRoutes folds, per matched route, the OPPOSITE side(s) fully and the
// matched side's own groups only on a whole-group change (outputGroups). A route has
// three peer sides — routing (Peer/PeerGroups), consumer (Groups) and ACL
// (AccessControlGroups) — that each refresh the others; the changed side's own group
// folds its siblings only when the group itself changed, never on a one-peer move.
func (r *resolver) collectFromRoutes() {
	for _, rt := range r.snap.routes {
		if !rt.Enabled {
			continue // disabled routes route to nobody; skip existing account data
		}
		routing := anyInSet(rt.PeerGroups, r.linkGroups) || (rt.Peer != "" && isInSet(rt.Peer, r.changedPeers))
		consumer := anyInSet(rt.Groups, r.linkGroups)
		acl := anyInSet(rt.AccessControlGroups, r.linkGroups)
		if !routing && !consumer && !acl {
			continue
		}
		log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (routing=%t consumer=%t acl=%t) -> folding opposite sides; own side gated on outputGroups",
			rt.ID, routing, consumer, acl)
		r.foldRouteSide(rt.PeerGroups, routing)
		r.foldRouteSide(rt.Groups, consumer)
		r.foldRouteSide(rt.AccessControlGroups, acl)
		// The single routing Peer folds when the routing side is the OPPOSITE of the
		// match (consumer/acl need it), or when that very peer is the change.
		if rt.Peer != "" && (consumer || acl || isInSet(rt.Peer, r.changedPeers)) {
			r.affectedPeers[rt.Peer] = struct{}{}
		}
	}
}

// foldRouteSide folds a route side: when this side is the one that matched, fold its
// groups only on a whole-group change (outputGroups) so siblings of a single moved
// peer stay put; otherwise it is an opposite side and folds fully.
func (r *resolver) foldRouteSide(groups []string, matchedHere bool) {
	if matchedHere {
		r.foldOutputGroups(groups)
		return
	}
	addAll(r.affectedGroups, groups)
}

// foldOutputGroups folds only the groups that the caller reported as wholly changed
// (outputGroups). Used for a matched object's OWN side, where a peer-seeded or
// link-only group must not pull in its siblings.
func (r *resolver) foldOutputGroups(groups ...[]string) {
	for _, gs := range groups {
		for _, gID := range gs {
			if _, ok := r.outputGroups[gID]; ok {
				r.affectedGroups[gID] = struct{}{}
			}
		}
	}
}

func (r *resolver) collectFromNameServers() {
	if len(r.linkGroups) == 0 {
		return
	}
	for _, ns := range r.snap.nsGroups {
		if anyInSet(ns.Groups, r.linkGroups) {
			// A nameserver group has no opposite side: a peer's DNS config depends only
			// on its own membership, so a one-peer move refreshes that peer alone (folded
			// elsewhere). Fold the referenced groups only on a whole-group change.
			log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a linked group -> folding its groups %v (outputGroups only)", ns.ID, ns.Groups)
			r.foldOutputGroups(ns.Groups)
		}
	}
}

// collectFromSSHAuthorizedGroups folds the destinations of the enabled SSH rules that
// authorize a group whose user membership changed. Those destination peers carry the
// group -> user mapping for the groups they authorize, so they refresh even when no
// peer moved between groups.
func (r *resolver) collectFromSSHAuthorizedGroups() {
	if len(r.change.UserGroupIDs) == 0 {
		return
	}

	changed := toSet(r.change.UserGroupIDs)
	for _, policy := range r.policies() {
		for _, rule := range policy.Rules {
			if !rule.Enabled || rule.Protocol != types.PolicyRuleProtocolNetbirdSSH {
				continue
			}
			if !anyInSet(maps.Keys(rule.AuthorizedGroups), changed) {
				continue
			}
			log.WithContext(r.ctx).Tracef("collectFromSSHAuthorizedGroups: rule %s authorizes a changed user group -> folding its destinations", rule.ID)
			r.foldPolicySideForRule(policy, rule, sideDestination)
		}
	}
}

// collectFromAllowedUsers folds the destinations of the rules that make a peer carry
// the account's allowed-user set, for a change to who is in that set.
func (r *resolver) collectFromAllowedUsers() {
	if !r.change.AllowedUsersChanged {
		return
	}

	for _, policy := range r.policies() {
		for _, rule := range policy.Rules {
			if !rule.Enabled || !ruleShipsAllowedUsers(rule) {
				continue
			}
			log.WithContext(r.ctx).Tracef("collectFromAllowedUsers: rule %s ships the allowed-user set -> folding its destinations", rule.ID)
			r.foldPolicySideForRule(policy, rule, sideDestination)
		}
	}
}

// ruleShipsAllowedUsers reports whether a rule makes its destination peers carry the
// account's allowed-user set. It mirrors the network map's SSH requirements except for
// the destination peer's own SSH flag, which the snapshot does not hold — so it folds a
// superset and never misses a peer.
func ruleShipsAllowedUsers(rule *types.PolicyRule) bool {
	if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
		return len(rule.AuthorizedGroups) == 0 && rule.AuthorizedUser == ""
	}
	return types.PolicyRuleImpliesLegacySSH(rule)
}

func (r *resolver) collectFromDNSSettings() {
	if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil {
		return
	}
	for _, gID := range r.snap.dnsSettings.DisabledManagementGroups {
		if _, ok := r.linkGroups[gID]; ok {
			log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID)
			r.affectedGroups[gID] = struct{}{}
		}
	}
}

// collectFromNetworkRouters handles a changed group/peer that BACKS a router (the
// routing peer set moved): the router's own peers refresh and so do the sources of
// the policies reaching its network's resources. Sibling routers on the network are
// independent and are not folded.
func (r *resolver) collectFromNetworkRouters() {
	for _, router := range r.networkRouters() {
		matchedByGroup := anyInSet(router.PeerGroups, r.linkGroups)
		matchedByPeer := router.Peer != "" && len(r.changedPeers) > 0 && isInSet(router.Peer, r.changedPeers)
		if !matchedByGroup && !matchedByPeer {
			continue
		}
		log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding its peerGroups=%v peer=%q (own groups on outputGroups) + sources reaching network resources",
			router.ID, router.NetworkID, matchedByGroup, matchedByPeer, router.PeerGroups, router.Peer)
		// The backing PeerGroups are the matched (own) side: fold them only on a
		// whole-group change so a one-peer move does not wake sibling backing peers. The
		// opposite side (policy sources reaching the network) is folded below.
		r.foldOutputGroups(router.PeerGroups)
		if router.Peer != "" {
			r.affectedPeers[router.Peer] = struct{}{}
		}
		if router.NetworkID != "" {
			r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID))
		}
	}
}

func (r *resolver) collectFromProxyServices() {
	if len(r.snap.proxyByCluster) == 0 || len(r.snap.services) == 0 {
		return
	}
	services, proxyByCluster := r.snap.services, r.snap.proxyByCluster

	expanded := r.expandChangedPeersWithGroups()

	for _, svc := range services {
		if svc == nil || !svc.Enabled {
			continue // a disabled service proxies nothing; skip existing account data
		}
		proxyPeers := proxyByCluster[svc.ProxyCluster]
		if len(proxyPeers) == 0 {
			continue
		}
		matchedByPeer := serviceMatchesChangedPeers(svc, proxyPeers, expanded)
		matchedByAccessGroup := anyInSet(svc.AccessGroups, r.linkGroups)
		if !matchedByPeer && !matchedByAccessGroup {
			continue
		}
		log.WithContext(r.ctx).Tracef("collectFromProxyServices: service %s (cluster=%s) matched (byProxyOrTargetPeer=%t byAccessGroup=%t) -> folding %d proxy peers, peer targets; access groups %v on outputGroups only",
			svc.ID, svc.ProxyCluster, matchedByPeer, matchedByAccessGroup, len(proxyPeers), svc.AccessGroups)
		for _, pid := range proxyPeers {
			r.affectedPeers[pid] = struct{}{}
		}
		for _, target := range svc.Targets {
			if !target.Enabled {
				continue // a disabled target forwards nothing
			}
			if target.TargetType == rpservice.TargetTypePeer && target.TargetId != "" {
				r.affectedPeers[target.TargetId] = struct{}{}
			}
		}
		// AccessGroups are the matched (own) side with no opposite to fold: a member's
		// proxy access is self-contained, so a one-peer move refreshes that peer alone.
		// Fold the groups only on a whole-group change.
		r.foldOutputGroups(svc.AccessGroups)
	}
}

func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} {
	if len(r.linkGroups) == 0 {
		return r.changedPeers
	}
	ids := r.peerIDsForGroups(r.linkGroups)
	if len(ids) == 0 {
		return r.changedPeers
	}
	merged := make(map[string]struct{}, len(r.changedPeers)+len(ids))
	for id := range r.changedPeers {
		merged[id] = struct{}{}
	}
	for _, id := range ids {
		merged[id] = struct{}{}
	}
	return merged
}

// foldRoutersForResources folds the routers serving the networks of the given
// resources (a destination resource is reached through its network's routers). It is
// the resource -> network -> router hop used by foldPolicySide for a destination.
func (r *resolver) foldRoutersForResources(resourceIDs map[string]struct{}) {
	if len(resourceIDs) == 0 {
		return
	}
	r.foldRoutersOnNetworks(r.resourceNetworkIDs(resourceIDs))
}

// ruleDestinationResourceIDs returns the destination resource IDs of a single rule:
// the direct DestinationResource plus the resources of its destination groups.
func (r *resolver) ruleDestinationResourceIDs(rule *types.PolicyRule) map[string]struct{} {
	resourceIDs := make(map[string]struct{})
	if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" {
		resourceIDs[rule.DestinationResource.ID] = struct{}{}
	}
	r.addGroupResourceIDs(toSet(rule.Destinations), resourceIDs)
	return resourceIDs
}

// networkResourceIDs returns the IDs of all resources on the given network.
func (r *resolver) networkResourceIDs(networkID string) map[string]struct{} {
	resourceIDs := make(map[string]struct{})
	for _, resource := range r.networkResources() {
		if resource.NetworkID == networkID {
			resourceIDs[resource.ID] = struct{}{}
		}
	}
	return resourceIDs
}

func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) {
	for _, router := range r.networkRouters() {
		if _, ok := networkIDs[router.NetworkID]; !ok {
			continue
		}
		log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: router %s serves affected network %s -> folding peerGroups=%v peer=%q",
			router.ID, router.NetworkID, router.PeerGroups, router.Peer)
		addAll(r.affectedGroups, router.PeerGroups)
		if router.Peer != "" {
			r.affectedPeers[router.Peer] = struct{}{}
		}
	}
}

func (r *resolver) resourceNetworkIDs(resourceIDs map[string]struct{}) map[string]struct{} {
	networkIDs := make(map[string]struct{})
	for _, resource := range r.networkResources() {
		if _, ok := resourceIDs[resource.ID]; ok {
			networkIDs[resource.NetworkID] = struct{}{}
		}
	}
	return networkIDs
}

func (r *resolver) policyTargetsResources(policy *types.Policy, resourceIDs map[string]struct{}) bool {
	if policy == nil {
		return false
	}
	destGroupSet := make(map[string]struct{})
	for _, rule := range policy.Rules {
		if !rule.Enabled {
			continue
		}
		if rule.DestinationResource.Type != types.ResourceTypePeer && isInSet(rule.DestinationResource.ID, resourceIDs) {
			return true
		}
		for _, gID := range rule.Destinations {
			destGroupSet[gID] = struct{}{}
		}
	}
	if len(destGroupSet) == 0 {
		return false
	}
	for gID := range destGroupSet {
		group := r.snap.groups[gID]
		if group == nil {
			continue
		}
		for _, res := range group.Resources {
			if isInSet(res.ID, resourceIDs) {
				return true
			}
		}
	}
	return false
}

func (r *resolver) policyDestinationResourceIDs(policies ...*types.Policy) map[string]struct{} {
	resourceIDs := make(map[string]struct{})
	destGroupSet := collectPolicyDestinations(resourceIDs, policies...)
	r.addGroupResourceIDs(destGroupSet, resourceIDs)
	return resourceIDs
}

// collectPolicyDestinations adds direct destination resource IDs to resourceIDs and
// returns the referenced destination group IDs.
func collectPolicyDestinations(resourceIDs map[string]struct{}, policies ...*types.Policy) map[string]struct{} {
	destGroupSet := make(map[string]struct{})
	for _, policy := range policies {
		if policy == nil {
			continue
		}
		for _, rule := range policy.Rules {
			addAll(destGroupSet, rule.Destinations)
			if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" {
				resourceIDs[rule.DestinationResource.ID] = struct{}{}
			}
		}
	}
	return destGroupSet
}

// addGroupResourceIDs folds the resource IDs of the given groups into resourceIDs.
func (r *resolver) addGroupResourceIDs(groupIDs map[string]struct{}, resourceIDs map[string]struct{}) {
	for gID := range groupIDs {
		group := r.snap.groups[gID]
		if group == nil {
			continue
		}
		for _, res := range group.Resources {
			if res.ID != "" {
				resourceIDs[res.ID] = struct{}{}
			}
		}
	}
}

// collectPolicySources folds the source groups/peers of a snapshot policy's enabled
// rules (a disabled rule grants no access).
func collectPolicySources(policy *types.Policy, groups, peers map[string]struct{}) {
	for _, rule := range policy.Rules {
		if !rule.Enabled {
			continue
		}
		addAll(groups, rule.Sources)
		if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
			peers[rule.SourceResource.ID] = struct{}{}
		}
	}
}

func policyReferencesPostureChecks(policy *types.Policy, ids map[string]struct{}) bool {
	for _, id := range policy.SourcePostureChecks {
		if _, ok := ids[id]; ok {
			return true
		}
	}
	return false
}

func isDirectPeerInSet(res types.Resource, set map[string]struct{}) bool {
	if res.Type != types.ResourceTypePeer || res.ID == "" {
		return false
	}
	_, ok := set[res.ID]
	return ok
}

func serviceMatchesChangedPeers(svc *rpservice.Service, proxyPeers []string, changedPeers map[string]struct{}) bool {
	for _, pid := range proxyPeers {
		if _, ok := changedPeers[pid]; ok {
			return true
		}
	}
	for _, target := range svc.Targets {
		if !target.Enabled || target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" {
			continue
		}
		if _, ok := changedPeers[target.TargetId]; ok {
			return true
		}
	}
	return false
}

func anyInSet(ids []string, set map[string]struct{}) bool {
	for _, id := range ids {
		if _, ok := set[id]; ok {
			return true
		}
	}
	return false
}

func isInSet(id string, set map[string]struct{}) bool {
	_, ok := set[id]
	return ok
}

func addAll(set map[string]struct{}, slices ...[]string) {
	for _, s := range slices {
		for _, id := range s {
			set[id] = struct{}{}
		}
	}
}

func toSet(ids []string) map[string]struct{} {
	set := make(map[string]struct{}, len(ids))
	for _, id := range ids {
		set[id] = struct{}{}
	}
	return set
}

func setToSlice(set map[string]struct{}) []string {
	s := make([]string, 0, len(set))
	for id := range set {
		s = append(s, id)
	}
	return s
}
