// Package state provides core state management for Headscale,
// coordinating between subsystems like database, IP allocation,
// policy management, and DERP routing.
//
// The central type [State] owns a copy-on-write [NodeStore]
// (node_store.go), a PrimaryRoutes HA ledger, the [policy.PolicyManager],
// and a [pingTracker] for [tailcfg.PingRequest] correlation.
// Cross-subsystem operations (node updates, policy evaluation, IP
// allocation) go through [State] rather than directly to the database.

package state

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"maps"
	"net/netip"
	"slices"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/hashicorp/golang-lru/v2/expirable"
	hsdb "github.com/juanfont/headscale/hscontrol/db"
	"github.com/juanfont/headscale/hscontrol/policy"
	"github.com/juanfont/headscale/hscontrol/policy/matcher"
	"github.com/juanfont/headscale/hscontrol/types"
	"github.com/juanfont/headscale/hscontrol/types/change"
	"github.com/juanfont/headscale/hscontrol/util"
	"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	"golang.org/x/sync/errgroup"
	"gorm.io/gorm"
	"tailscale.com/tailcfg"
	"tailscale.com/types/key"
	"tailscale.com/types/views"
	"tailscale.com/util/dnsname"
)

const (
	// registerCacheExpiration defines how long node registration entries remain in cache.
	registerCacheExpiration = time.Minute * 15

	// defaultRegisterCacheMaxEntries is the default upper bound on the number
	// of pending registration entries the auth cache will hold. With a 15-minute
	// TTL and a stripped-down RegistrationData payload (~200 bytes per entry),
	// 1024 entries cap the worst-case cache footprint at well under 1 MiB even
	// under sustained unauthenticated cache-fill attempts.
	defaultRegisterCacheMaxEntries = 1024

	// defaultNodeStoreBatchSize is the default number of write operations to batch
	// before rebuilding the in-memory node snapshot.
	defaultNodeStoreBatchSize = 100

	// defaultNodeStoreBatchTimeout is the default maximum time to wait before
	// processing a partial batch of node operations.
	defaultNodeStoreBatchTimeout = 500 * time.Millisecond
)

// ErrUnsupportedPolicyMode is returned for invalid policy modes. Valid modes are "file" and "db".
var ErrUnsupportedPolicyMode = errors.New("unsupported policy mode")

// ErrNodeNotFound is returned when a node cannot be found by its ID.
var ErrNodeNotFound = errors.New("node not found")

// ErrInvalidNodeView is returned when an invalid node view is provided.
var ErrInvalidNodeView = errors.New("invalid node view provided")

// ErrNodeNotInNodeStore is returned when a node no longer exists in the [NodeStore].
var ErrNodeNotInNodeStore = errors.New("node no longer exists in NodeStore")

// ErrNodeNameNotUnique is returned when a node name is not unique.
var ErrNodeNameNotUnique = errors.New("node name is not unique")

// nodeUpdateColumns lists all Node columns that should be written
// during a struct-based GORM Updates() call.  Listing them explicitly
// forces GORM to include nil/zero-value fields (e.g. UserID=nil when
// converting a user-owned node to tagged) that struct-based Updates()
// would otherwise silently skip.
//
// Excluded columns:
//   - AuthKeyID, AuthKey: prevents GORM from persisting stale
//     PreAuthKey references after a key has been deleted (#2862).
//   - User: GORM association, not a real column.
//   - IsOnline: runtime-only field (gorm:"-").
//
// Expiry is included here but may be omitted at call sites that must
// not touch it (see persistNodeToDB).
var nodeUpdateColumns = []string{
	"MachineKey",
	"NodeKey",
	"DiscoKey",
	"Endpoints",
	"Hostinfo",
	"IPv4",
	"IPv6",
	"Hostname",
	"GivenName",
	"UserID",
	"RegisterMethod",
	"Tags",
	"Expiry",
	"LastSeen",
	"ApprovedRoutes",
	"UpdatedAt",
}

// ErrRegistrationExpired is returned when a registration has expired.
var ErrRegistrationExpired = errors.New("registration expired")

// sshCheckPair identifies a (source, destination) node pair for
// SSH check auth tracking.
type sshCheckPair struct {
	Src types.NodeID
	Dst types.NodeID
}

// State manages Headscale's core state, coordinating between database, policy management,
// IP allocation, and DERP routing. All methods are thread-safe.
//
// See [policy.PolicyManager] for policy evaluation and [NodeStore] for the
// in-memory node cache.
type State struct {
	// cfg holds the current Headscale configuration
	cfg *types.Config

	// nodeStore provides an in-memory cache for nodes.
	nodeStore *NodeStore

	// subsystem keeping state
	// db provides persistent storage and database operations
	db *hsdb.HSDatabase
	// ipAlloc manages IP address allocation for nodes
	ipAlloc *hsdb.IPAllocator
	// derpMap contains the current DERP relay configuration
	derpMap atomic.Pointer[tailcfg.DERPMap]
	// polMan handles policy evaluation and management
	polMan policy.PolicyManager

	// authCache holds any pending authentication requests from either auth
	// type (Web and OIDC). It is a bounded LRU keyed by AuthID; oldest
	// entries are evicted once the size cap is reached, and entries that
	// time out have their auth verdict resolved with ErrRegistrationExpired
	// via the eviction callback so any waiting goroutines wake.
	authCache *expirable.LRU[types.AuthID, *types.AuthRequest]

	// pings tracks pending ping requests and their response channels.
	pings *pingTracker

	// sshCheckAuth tracks when source nodes last completed SSH check auth.
	//
	// For rules without explicit checkPeriod (default 12h), auth covers any
	// destination — keyed by (src, Dst=0) where 0 is a sentinel meaning "any".
	// Ref: "Once re-authenticated to a destination, the user can access the
	// device and any other device in the tailnet without re-verification
	// for the next 12 hours." — https://tailscale.com/docs/features/tailscale-ssh
	//
	// For rules with explicit checkPeriod, auth covers only that specific
	// destination — keyed by (src, dst).
	// Ref: "If a different check period is specified for the connection,
	// then the user can access specifically this device without
	// re-verification for the duration of the check period."
	//
	// Ref: https://github.com/tailscale/tailscale/issues/10480
	// Ref: https://github.com/tailscale/tailscale/issues/7125
	sshCheckAuth map[sshCheckPair]time.Time
	sshCheckMu   sync.RWMutex
}

// NewState creates and initializes a new [State] instance, setting up the database,
// IP allocator, DERP map, policy manager, and loading existing users and nodes.
func NewState(cfg *types.Config) (*State, error) {
	cacheExpiration := registerCacheExpiration
	if cfg.Tuning.RegisterCacheExpiration != 0 {
		cacheExpiration = cfg.Tuning.RegisterCacheExpiration
	}

	cacheMaxEntries := defaultRegisterCacheMaxEntries
	if cfg.Tuning.RegisterCacheMaxEntries > 0 {
		cacheMaxEntries = cfg.Tuning.RegisterCacheMaxEntries
	}

	authCache := expirable.NewLRU[types.AuthID, *types.AuthRequest](
		cacheMaxEntries,
		func(id types.AuthID, rn *types.AuthRequest) {
			rn.FinishAuth(types.AuthVerdict{Err: ErrRegistrationExpired})
		},
		cacheExpiration,
	)

	db, err := hsdb.NewHeadscaleDatabase(cfg)
	if err != nil {
		return nil, fmt.Errorf("initializing database: %w", err)
	}

	ipAlloc, err := hsdb.NewIPAllocator(db, cfg.PrefixV4, cfg.PrefixV6, cfg.IPAllocation)
	if err != nil {
		return nil, fmt.Errorf("initializing IP allocator: %w", err)
	}

	nodes, err := db.ListNodes()
	if err != nil {
		return nil, fmt.Errorf("loading nodes: %w", err)
	}

	// On startup, all nodes should be marked as offline until they reconnect
	// This ensures we don't have stale online status from previous runs
	for _, node := range nodes {
		node.IsOnline = new(false)
	}

	users, err := db.ListUsers()
	if err != nil {
		return nil, fmt.Errorf("loading users: %w", err)
	}

	pol, err := hsdb.PolicyBytes(db.DB, cfg)
	if err != nil {
		return nil, fmt.Errorf("loading policy: %w", err)
	}

	polMan, err := policy.NewPolicyManager(pol, users, nodes.ViewSlice())
	if err != nil {
		return nil, fmt.Errorf("initializing policy manager: %w", err)
	}

	// Apply defaults for [NodeStore] batch configuration if not set.
	// This ensures tests that create Config directly (without viper) still work.
	batchSize := cfg.Tuning.NodeStoreBatchSize
	if batchSize == 0 {
		batchSize = defaultNodeStoreBatchSize
	}

	batchTimeout := cfg.Tuning.NodeStoreBatchTimeout
	if batchTimeout == 0 {
		batchTimeout = defaultNodeStoreBatchTimeout
	}

	// [policy.PolicyManager.BuildPeerMap] handles both global and per-node filter complexity.
	// This moves the complex peer relationship logic into the policy package where it belongs.
	nodeStore := NewNodeStore(
		nodes,
		func(nodes []types.NodeView) map[types.NodeID][]types.NodeView {
			return polMan.BuildPeerMap(views.SliceOf(nodes))
		},
		batchSize,
		batchTimeout,
	)
	nodeStore.Start()

	return &State{
		cfg: cfg,

		db:        db,
		ipAlloc:   ipAlloc,
		polMan:    polMan,
		authCache: authCache,
		nodeStore: nodeStore,
		pings:     newPingTracker(),

		sshCheckAuth: make(map[sshCheckPair]time.Time),
	}, nil
}

// Close gracefully shuts down the [State] instance and releases all resources.
func (s *State) Close() error {
	s.pings.drain()
	s.nodeStore.Stop()

	err := s.db.Close()
	if err != nil {
		return fmt.Errorf("closing database: %w", err)
	}

	return nil
}

// SetDERPMap updates the DERP relay configuration.
func (s *State) SetDERPMap(dm *tailcfg.DERPMap) {
	s.derpMap.Store(dm)
}

// DERPMap returns the current DERP relay configuration for peer-to-peer connectivity.
func (s *State) DERPMap() tailcfg.DERPMapView {
	return s.derpMap.Load().View()
}

// ReloadPolicy reloads the access control policy and triggers auto-approval if changed.
// Returns the resulting [change.Change] slice when the policy or routes changed.
func (s *State) ReloadPolicy() ([]change.Change, error) {
	pol, err := hsdb.PolicyBytes(s.db.DB, s.cfg)
	if err != nil {
		return nil, fmt.Errorf("loading policy: %w", err)
	}

	policyChanged, err := s.polMan.SetPolicy(pol)
	if err != nil {
		return nil, fmt.Errorf("setting policy: %w", err)
	}

	// Clear SSH check auth times when policy changes to ensure stale
	// approvals don't persist if checkPeriod rules are modified or removed.
	s.ClearSSHCheckAuth()

	// Rebuild peer maps after policy changes because the peersFunc in [NodeStore]
	// uses the [policy.PolicyManager]'s filters. Without this, nodes won't see
	// newly allowed peers until a node is added/removed, causing autogroup:self
	// policies to not propagate correctly when switching between policy types.
	s.nodeStore.RebuildPeerMaps()

	//nolint:prealloc // cs starts with one element and may grow
	cs := []change.Change{change.PolicyChange()}

	// Per-node selective self refresh for nodeAttrs. A broadcast
	// [change.PolicyChange] re-renders peer lists and packet filters
	// but never repopulates a node's own [tailcfg.Node.CapMap]; that
	// lives on the self entry only. The drain returns every node ID
	// whose cap output shifted across recent updateLocked calls —
	// refreshNodeAttrsLocked appends rather than overwrites so a
	// concurrent SetUsers/SetNodes between SetPolicy and the drain
	// cannot silently lose the policy-reload diff.
	for _, id := range s.polMan.NodesWithChangedCapMap() {
		cs = append(cs, change.SelfUpdate(id))
	}

	// Always call autoApproveNodes during policy reload, regardless of whether
	// the policy content has changed. This ensures that routes are re-evaluated
	// when they might have been manually disabled but could now be auto-approved
	// with the current policy.
	rcs, err := s.autoApproveNodes()
	if err != nil {
		return nil, fmt.Errorf("auto approving nodes: %w", err)
	}

	// TODO(kradalby): These changes can probably be safely ignored.
	// If the PolicyChange is happening, that will lead to a full update
	// meaning that we do not need to send individual route changes.
	cs = append(cs, rcs...)

	if len(rcs) > 0 || policyChanged {
		log.Info().
			Bool("policy.changed", policyChanged).
			Int("route.changes", len(rcs)).
			Int("total.changes", len(cs)).
			Msg("Policy reload completed with changes")
	}

	return cs, nil
}

// CreateUser creates a new user and updates the policy manager.
// Returns the created user, change set, and any error.
func (s *State) CreateUser(user types.User) (*types.User, change.Change, error) {
	if err := s.db.DB.Save(&user).Error; err != nil { //nolint:noinlineerr
		return nil, change.Change{}, fmt.Errorf("creating user: %w", err)
	}

	// Check if policy manager needs updating
	c, err := s.updatePolicyManagerUsers()
	if err != nil {
		// Log the error but don't fail the user creation
		return &user, change.Change{}, fmt.Errorf("updating policy manager after user creation: %w", err)
	}

	// Even if the policy manager doesn't detect a filter change, SSH policies
	// might now be resolvable when they weren't before. If there are existing
	// nodes, we should send a policy change to ensure they get updated SSH policies.
	// TODO(kradalby): detect this, or rebuild all SSH policies so we can determine
	// this upstream.
	if c.IsEmpty() {
		c = change.PolicyChange()
	}

	log.Info().Str(zf.UserName, user.Name).Msg("user created")

	return &user, c, nil
}

// UpdateUser modifies an existing user using the provided update function within a transaction.
// Returns the updated user, change set, and any error.
func (s *State) UpdateUser(userID types.UserID, updateFn func(*types.User) error) (*types.User, change.Change, error) {
	user, err := hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.User, error) {
		user, err := hsdb.GetUserByID(tx, userID)
		if err != nil {
			return nil, err
		}

		if err := updateFn(user); err != nil { //nolint:noinlineerr
			return nil, err
		}

		// Use Updates() to only update modified fields, preserving unchanged values.
		err = tx.Updates(user).Error
		if err != nil {
			return nil, fmt.Errorf("updating user: %w", err)
		}

		return user, nil
	})
	if err != nil {
		return nil, change.Change{}, err
	}

	// Check if policy manager needs updating
	c, err := s.updatePolicyManagerUsers()
	if err != nil {
		return user, change.Change{}, fmt.Errorf("updating policy manager after user update: %w", err)
	}

	// TODO(kradalby): We might want to update nodestore with the user data

	return user, c, nil
}

// DeleteUser permanently removes a user and all associated data (nodes, API keys, etc).
// This operation is irreversible.
// It also updates the policy manager to ensure ACL policies referencing the deleted
// user are re-evaluated immediately, fixing issue #2967.
func (s *State) DeleteUser(userID types.UserID) (change.Change, error) {
	err := s.db.DestroyUser(userID)
	if err != nil {
		return change.Change{}, err
	}

	// Update policy manager with the new user list (without the deleted user)
	// This ensures that if the policy references the deleted user, it gets
	// re-evaluated immediately rather than when some other operation triggers it.
	c, err := s.updatePolicyManagerUsers()
	if err != nil {
		return change.Change{}, fmt.Errorf("updating policy after user deletion: %w", err)
	}

	// If the policy manager doesn't detect changes, still return UserRemoved
	// to ensure peer lists are refreshed
	if c.IsEmpty() {
		c = change.UserRemoved()
	}

	return c, nil
}

// RenameUser changes a user's name. The new name must be unique.
func (s *State) RenameUser(userID types.UserID, newName string) (*types.User, change.Change, error) {
	return s.UpdateUser(userID, func(user *types.User) error {
		user.Name = newName
		return nil
	})
}

// GetUserByID retrieves a user by ID.
func (s *State) GetUserByID(userID types.UserID) (*types.User, error) {
	return s.db.GetUserByID(userID)
}

// GetUserByName retrieves a user by name.
func (s *State) GetUserByName(name string) (*types.User, error) {
	return s.db.GetUserByName(name)
}

// GetUserByOIDCIdentifier retrieves a user by their OIDC identifier.
func (s *State) GetUserByOIDCIdentifier(id string) (*types.User, error) {
	return s.db.GetUserByOIDCIdentifier(id)
}

// ListUsersWithFilter retrieves users matching the specified filter criteria.
func (s *State) ListUsersWithFilter(filter *types.User) ([]types.User, error) {
	return s.db.ListUsers(filter)
}

// ListAllUsers retrieves all users in the system.
func (s *State) ListAllUsers() ([]types.User, error) {
	return s.db.ListUsers()
}

// persistNodeToDB saves the given node state to the database.
// This function must receive the exact node state to save to ensure consistency between
// [NodeStore] and the database. It verifies the node still exists in [NodeStore] to
// prevent race conditions where a node might be deleted between [NodeStore.UpdateNode]
// returning and persistNodeToDB being called.
func (s *State) persistNodeToDB(node types.NodeView) (types.NodeView, change.Change, error) {
	if !node.Valid() {
		return types.NodeView{}, change.Change{}, ErrInvalidNodeView
	}

	// Verify the node still exists in [NodeStore] before persisting to database.
	// Without this check, we could hit a race condition where [NodeStore.UpdateNode]
	// returns a valid node from a batch update, then the node gets deleted (e.g.,
	// ephemeral node logout), and persistNodeToDB would incorrectly re-insert the
	// deleted node into the database.
	_, exists := s.nodeStore.GetNode(node.ID())
	if !exists {
		log.Warn().
			EmbedObject(node).
			Bool("is_ephemeral", node.IsEphemeral()).
			Msg("Node no longer exists in NodeStore, skipping database persist to prevent race condition")

		return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, node.ID())
	}

	nodePtr := node.AsStruct()

	// Explicitly select all node columns so GORM includes nil/zero-value
	// fields (e.g. UserID=nil when converting a user-owned node to tagged).
	// Omit "Expiry" here: expiry is only updated through explicit
	// SetNodeExpiry calls or re-registration, not during MapRequest updates.
	err := s.db.DB.Select(nodeUpdateColumns).Omit("Expiry").Updates(nodePtr).Error
	if err != nil {
		return types.NodeView{}, change.Change{}, fmt.Errorf("saving node: %w", err)
	}

	// Check if policy manager needs updating
	c, err := s.updatePolicyManagerNodes()
	if err != nil {
		return nodePtr.View(), change.Change{}, fmt.Errorf("updating policy manager after node save: %w", err)
	}

	if c.IsEmpty() {
		c = change.NodeAdded(node.ID())
	}

	return node, c, nil
}

func (s *State) SaveNode(node types.NodeView) (types.NodeView, change.Change, error) {
	// Update [NodeStore] first
	nodePtr := node.AsStruct()

	resultNode := s.nodeStore.PutNode(*nodePtr)

	// Then save to database using the result from [NodeStore.PutNode]
	return s.persistNodeToDB(resultNode)
}

// DeleteNode permanently removes a node and cleans up associated resources.
// Returns whether policies changed and any error. This operation is irreversible.
func (s *State) DeleteNode(node types.NodeView) (change.Change, error) {
	s.nodeStore.DeleteNode(node.ID())

	err := s.db.DeleteNode(node.AsStruct())
	if err != nil {
		return change.Change{}, err
	}

	s.ipAlloc.FreeIPs(node.IPs())

	c := change.NodeRemoved(node.ID())

	// Check if policy manager needs updating after node deletion
	policyChange, err := s.updatePolicyManagerNodes()
	if err != nil {
		return change.Change{}, fmt.Errorf("updating policy manager after node deletion: %w", err)
	}

	if !policyChange.IsEmpty() {
		// Merge policy change with NodeRemoved to preserve PeersRemoved info
		// This ensures the batcher cleans up the deleted node from its state
		c = c.Merge(policyChange)
	}

	return c, nil
}

// Connect marks a node connected and returns the resulting changes
// plus a session epoch. The caller must pass the epoch back to
// [State.Disconnect] so deferred grace-period disconnects from a
// previous poll session are dropped (see poll.go).
func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) {
	prevRoutes := s.nodeStore.PrimaryRoutes()

	// Reconnecting clears Unhealthy: the node just proved basic
	// connectivity by completing the Noise handshake.
	var epoch uint64

	node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
		n.SessionEpoch++
		epoch = n.SessionEpoch
		n.IsOnline = new(true)
		n.Unhealthy = false
	})
	if !ok {
		return nil, 0
	}

	c := []change.Change{change.NodeOnlineFor(node)}

	log.Info().EmbedObject(node).Msg("node connected")

	if !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes()) {
		c = append(c, change.NodeAdded(id))
	}

	// Coming online may re-enable cap/relay grants and identity-based
	// aliases targeting this node, so peers need a fresh netmap.
	c = append(c, change.PolicyChange())

	return c, epoch
}

// Disconnect marks the node offline. epoch must match the value
// [State.Connect] returned for this session; otherwise the call no-ops
// so a deferred disconnect from an older session cannot overwrite state
// set by a newer [State.Connect]. The check and the IsOnline write share
// an [NodeStore.UpdateNode] closure, making them atomic against
// concurrent connects.
func (s *State) Disconnect(id types.NodeID, epoch uint64) ([]change.Change, error) {
	var stale bool

	node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
		if n.SessionEpoch != epoch {
			stale = true
			return
		}

		now := time.Now()
		n.LastSeen = &now
		n.IsOnline = new(false)
		// Offline nodes are not HA candidates; drop any stale
		// Unhealthy bit so it does not surface in DebugRoutes.
		n.Unhealthy = false
	})

	if !ok {
		return nil, fmt.Errorf("%w: %d", ErrNodeNotFound, id)
	}

	if stale {
		log.Debug().
			Uint64("disconnect_epoch", epoch).
			Uint64("current_epoch", node.SessionEpoch()).
			Msg("stale disconnect rejected, newer session active")

		return nil, nil
	}

	log.Info().EmbedObject(node).Msg("node disconnected")

	// Persist LastSeen best-effort: [NodeStore] already reflects offline
	// and peers still need the change notifications below.
	_, c, err := s.persistNodeToDB(node)
	if err != nil {
		log.Error().Err(err).EmbedObject(node).Msg("failed to update last seen in database")

		c = change.Change{}
	}

	// Going offline can affect policy compilation beyond subnet routes
	// (cap/relay grants, tag/group aliases, via routes), so peers need
	// a fresh netmap regardless of whether the primary moved.
	//
	// TODO(kradalby): fires one full netmap recompute per peer on
	// every connect/disconnect. Coalesce in mapper/batcher.go:addToBatch.
	cs := []change.Change{change.NodeOfflineFor(node), c, change.PolicyChange()}

	return cs, nil
}

// GetNodeByID retrieves a node by ID.
// GetNodeByID retrieves a node by its ID.
// The bool indicates if the node exists or is available (like "err not found").
// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure
// it isn't an invalid node (this is more of a node error or node is broken).
func (s *State) GetNodeByID(nodeID types.NodeID) (types.NodeView, bool) {
	return s.nodeStore.GetNode(nodeID)
}

// GetNodeByNodeKey retrieves a node by its Tailscale public key.
// The bool indicates if the node exists or is available (like "err not found").
// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure
// it isn't an invalid node (this is more of a node error or node is broken).
func (s *State) GetNodeByNodeKey(nodeKey key.NodePublic) (types.NodeView, bool) {
	return s.nodeStore.GetNodeByNodeKey(nodeKey)
}

// GetNodeByMachineKey retrieves a node by its machine key and user ID.
// The bool indicates if the node exists or is available (like "err not found").
// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure
// it isn't an invalid node (this is more of a node error or node is broken).
func (s *State) GetNodeByMachineKey(machineKey key.MachinePublic, userID types.UserID) (types.NodeView, bool) {
	return s.nodeStore.GetNodeByMachineKey(machineKey, userID)
}

// ResolveNode looks up a node by numeric ID, IPv4/IPv6 address, given
// name, or hostname. It tries ID first, then IP, then GivenName
// (unique per tailnet), then Hostname (client-reported, may collide).
// Within the name passes, the lowest NodeID wins so repeated calls
// are deterministic across snapshot iterations.
func (s *State) ResolveNode(query string) (types.NodeView, bool) {
	// Try numeric ID first.
	id, idErr := types.ParseNodeID(query)
	if idErr == nil {
		return s.GetNodeByID(id)
	}

	// Try IP address.
	addr, addrErr := netip.ParseAddr(query)
	if addrErr == nil {
		var match types.NodeView

		for _, n := range s.ListNodes().All() {
			if !slices.Contains(n.IPs(), addr) {
				continue
			}

			if !match.Valid() || n.ID() < match.ID() {
				match = n
			}
		}

		return match, match.Valid()
	}

	// Try GivenName then Hostname, each with a stable tie-break on
	// lowest NodeID.
	var givenMatch, hostMatch types.NodeView

	for _, n := range s.ListNodes().All() {
		if n.GivenName() == query {
			if !givenMatch.Valid() || n.ID() < givenMatch.ID() {
				givenMatch = n
			}
		} else if n.Hostname() == query {
			if !hostMatch.Valid() || n.ID() < hostMatch.ID() {
				hostMatch = n
			}
		}
	}

	if givenMatch.Valid() {
		return givenMatch, true
	}

	return hostMatch, hostMatch.Valid()
}

// ListNodes retrieves specific nodes by ID, or all nodes if no IDs provided.
func (s *State) ListNodes(nodeIDs ...types.NodeID) views.Slice[types.NodeView] {
	if len(nodeIDs) == 0 {
		return s.nodeStore.ListNodes()
	}

	// Filter nodes by the requested IDs
	allNodes := s.nodeStore.ListNodes()

	nodeIDSet := make(map[types.NodeID]struct{}, len(nodeIDs))
	for _, id := range nodeIDs {
		nodeIDSet[id] = struct{}{}
	}

	var filteredNodes []types.NodeView

	for _, node := range allNodes.All() {
		if _, exists := nodeIDSet[node.ID()]; exists {
			filteredNodes = append(filteredNodes, node)
		}
	}

	return views.SliceOf(filteredNodes)
}

// ListNodesByUser retrieves all nodes belonging to a specific user.
func (s *State) ListNodesByUser(userID types.UserID) views.Slice[types.NodeView] {
	return s.nodeStore.ListNodesByUser(userID)
}

// ListPeers retrieves nodes that can communicate with the specified node based on policy.
func (s *State) ListPeers(nodeID types.NodeID, peerIDs ...types.NodeID) views.Slice[types.NodeView] {
	if len(peerIDs) == 0 {
		return s.nodeStore.ListPeers(nodeID)
	}

	// For specific peerIDs, filter from all nodes.
	// This path is used for incremental updates (NodeAdded, NodeChanged)
	// where the caller already knows which peer IDs are involved.
	// The peer visibility filtering happens in the mapper's buildTailPeers
	// via MatchersForNode/ReduceNodes.
	allNodes := s.nodeStore.ListNodes()

	nodeIDSet := make(map[types.NodeID]struct{}, len(peerIDs))
	for _, id := range peerIDs {
		nodeIDSet[id] = struct{}{}
	}

	var filteredNodes []types.NodeView

	for _, node := range allNodes.All() {
		if _, exists := nodeIDSet[node.ID()]; exists {
			filteredNodes = append(filteredNodes, node)
		}
	}

	return views.SliceOf(filteredNodes)
}

// ListEphemeralNodes retrieves all ephemeral (temporary) nodes in the system.
func (s *State) ListEphemeralNodes() views.Slice[types.NodeView] {
	allNodes := s.nodeStore.ListNodes()

	var ephemeralNodes []types.NodeView

	for _, node := range allNodes.All() {
		// Check if node is ephemeral by checking its AuthKey
		if node.AuthKey().Valid() && node.AuthKey().Ephemeral() {
			ephemeralNodes = append(ephemeralNodes, node)
		}
	}

	return views.SliceOf(ephemeralNodes)
}

// SetNodeExpiry updates the expiration time for a node.
// If expiry is nil, the node's expiry is disabled (node will never expire).
func (s *State) SetNodeExpiry(nodeID types.NodeID, expiry *time.Time) (types.NodeView, change.Change, error) {
	// Update [NodeStore] before database to ensure consistency. The [NodeStore] update
	// is blocking and will be the source of truth for the batcher. The database update
	// must make the exact same change. If the database update fails, the [NodeStore]
	// change will remain, but since we return an error, no change notification will be
	// sent to the batcher, preventing inconsistent state propagation.
	n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
		node.Expiry = expiry
	})

	if !ok {
		return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, nodeID)
	}

	// Persist expiry change to database directly since persistNodeToDB omits expiry.
	err := s.db.NodeSetExpiry(nodeID, expiry)
	if err != nil {
		return types.NodeView{}, change.Change{}, fmt.Errorf("setting node expiry in database: %w", err)
	}

	// Update policy manager and generate change notification.
	c, err := s.updatePolicyManagerNodes()
	if err != nil {
		return n, change.Change{}, fmt.Errorf("updating policy manager after setting expiry: %w", err)
	}

	if c.IsEmpty() {
		c = change.NodeAdded(n.ID())
	}

	return n, c, nil
}

// SetNodeTags assigns tags to a node, making it a "tagged node".
// Once a node is tagged, it cannot be un-tagged (only tags can be changed).
// Setting tags clears UserID since tagged nodes are owned by their tags.
func (s *State) SetNodeTags(nodeID types.NodeID, tags []string) (types.NodeView, change.Change, error) {
	if len(tags) == 0 {
		return types.NodeView{}, change.Change{}, types.ErrCannotRemoveAllTags
	}

	// Get node for validation
	existingNode, exists := s.nodeStore.GetNode(nodeID)
	if !exists {
		return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotFound, nodeID)
	}

	// Validate tags: must have correct format and exist in policy
	validatedTags := make([]string, 0, len(tags))
	invalidTags := make([]string, 0)

	for _, tag := range tags {
		if !strings.HasPrefix(tag, "tag:") || !s.polMan.TagExists(tag) {
			invalidTags = append(invalidTags, tag)

			continue
		}

		validatedTags = append(validatedTags, tag)
	}

	if len(invalidTags) > 0 {
		return types.NodeView{}, change.Change{}, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, invalidTags)
	}

	slices.Sort(validatedTags)
	validatedTags = slices.Compact(validatedTags)

	// Log the operation
	logTagOperation(existingNode, validatedTags)

	// Update [NodeStore] before database to ensure consistency. The [NodeStore] update
	// is blocking and will be the source of truth for the batcher. The database update
	// must make the exact same change.
	n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
		node.Tags = validatedTags
		// Tagged nodes are owned by their tags, not a user.
		node.UserID = nil
		node.User = nil
	})

	if !ok {
		return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, nodeID)
	}

	nodeView, c, err := s.persistNodeToDB(n)
	if err != nil {
		return nodeView, c, err
	}

	// Set OriginNode so the mapper knows to include self info for this node.
	// When tags change, persistNodeToDB returns PolicyChange which doesn't set OriginNode,
	// so the mapper's self-update check fails and the node never sees its new tags.
	// Setting OriginNode ensures the node gets a self-update with the new tags.
	c.OriginNode = nodeID

	return nodeView, c, nil
}

// SetApprovedRoutes sets the network routes that a node is approved to advertise.
func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (types.NodeView, change.Change, error) {
	// TODO(kradalby): In principle we should call the AutoApprove logic here
	// because even if the CLI removes an auto-approved route, it will be added
	// back automatically.
	prevRoutes := s.nodeStore.PrimaryRoutes()

	n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
		node.ApprovedRoutes = routes
		// A node with no approved routes is no longer an HA
		// candidate; drop any stale Unhealthy bit (mirrors the
		// legacy routes.SetRoutes(empty) auto-clear).
		if len(node.AllApprovedRoutes()) == 0 {
			node.Unhealthy = false
		}
	})

	if !ok {
		return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, nodeID)
	}

	// Persist the node changes to the database
	nodeView, c, err := s.persistNodeToDB(n)
	if err != nil {
		return types.NodeView{}, change.Change{}, err
	}

	// PolicyChange fans out a fresh netmap whenever the new approved
	// set shifted a primary advertiser.
	routeChange := !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes())
	if routeChange || !c.IsFull() {
		c = change.PolicyChange()
	}

	return nodeView, c, nil
}

// RenameNode changes the display name of a node. The admin supplies
// the exact DNS label they want; malformed input is rejected (no
// auto-sanitisation) and collisions error out rather than silently
// bumping a user-facing label. See HOSTNAME.md for the CLI contract.
func (s *State) RenameNode(nodeID types.NodeID, newName string) (types.NodeView, change.Change, error) {
	err := dnsname.ValidLabel(newName)
	if err != nil {
		return types.NodeView{}, change.Change{}, fmt.Errorf("renaming node: %w", err)
	}

	view, err := s.nodeStore.SetGivenName(nodeID, newName)
	if err != nil {
		switch {
		case errors.Is(err, ErrGivenNameTaken):
			return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %s", ErrNodeNameNotUnique, newName)
		case errors.Is(err, ErrNodeNotFound):
			return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, nodeID)
		default:
			return types.NodeView{}, change.Change{}, fmt.Errorf("renaming node: %w", err)
		}
	}

	return s.persistNodeToDB(view)
}

// BackfillNodeIPs assigns IP addresses to nodes that don't have them.
func (s *State) BackfillNodeIPs() ([]string, error) {
	changes, err := s.db.BackfillNodeIPs(s.ipAlloc)
	if err != nil {
		return nil, err
	}

	// Refresh [NodeStore] after IP changes to ensure consistency
	if len(changes) > 0 {
		nodes, err := s.db.ListNodes()
		if err != nil {
			return changes, fmt.Errorf("refreshing NodeStore after IP backfill: %w", err)
		}

		for _, node := range nodes {
			// Preserve online status and NetInfo when refreshing from database
			existingNode, exists := s.nodeStore.GetNode(node.ID)
			if exists && existingNode.Valid() {
				node.IsOnline = new(existingNode.IsOnline().Get())

				// TODO(kradalby): We should ensure we use the same hostinfo and node merge semantics
				// when a node re-registers as we do when it sends a map request (UpdateNodeFromMapRequest).

				// Preserve NetInfo from existing node to prevent loss during backfill
				netInfo := netInfoFromMapRequest(node.ID, existingNode.Hostinfo().AsStruct(), node.Hostinfo)
				node.Hostinfo = existingNode.Hostinfo().AsStruct()
				node.Hostinfo.NetInfo = netInfo
			}
			// TODO(kradalby): This should just update the IP addresses, nothing else in the node store.
			// We should avoid [NodeStore.PutNode] here.
			_ = s.nodeStore.PutNode(*node)
		}
	}

	return changes, nil
}

// ExpireExpiredNodes finds and processes expired nodes since the last check.
// Returns next check time, state update with expired nodes, and whether any were found.
func (s *State) ExpireExpiredNodes(lastCheck time.Time) (time.Time, []change.Change, bool) {
	// Why capture start time: We need to ensure we don't miss nodes that expire
	// while this function is running by using a consistent timestamp for the next check
	started := time.Now()

	var updates []change.Change

	for _, node := range s.nodeStore.ListNodes().All() { //nolint:unqueryvet // NodeStore.ListNodes not a SQL query
		if !node.Valid() {
			continue
		}

		// Why check After(lastCheck): We only want to notify about nodes that
		// expired since the last check to avoid duplicate notifications
		if node.IsExpired() && node.Expiry().Valid() && node.Expiry().Get().After(lastCheck) {
			updates = append(updates, change.KeyExpiryFor(node.ID(), node.Expiry().Get()))
		}
	}

	if len(updates) > 0 {
		return started, updates, true
	}

	return started, nil, false
}

// SSHPolicy returns the SSH access policy for a node.
func (s *State) SSHPolicy(node types.NodeView) (*tailcfg.SSHPolicy, error) {
	return s.polMan.SSHPolicy(s.cfg.ServerURL, node)
}

// SSHCheckParams resolves the SSH check period for a source-destination
// node pair from the current policy.
func (s *State) SSHCheckParams(
	srcNodeID, dstNodeID types.NodeID,
) (time.Duration, bool) {
	return s.polMan.SSHCheckParams(srcNodeID, dstNodeID)
}

// Filter returns the current network filter rules and matches.
func (s *State) Filter() ([]tailcfg.FilterRule, []matcher.Match) {
	return s.polMan.Filter()
}

// FilterForNode returns filter rules for a specific node, handling autogroup:self per-node.
func (s *State) FilterForNode(node types.NodeView) ([]tailcfg.FilterRule, error) {
	return s.polMan.FilterForNode(node)
}

// MatchersForNode returns matchers for peer relationship determination (unreduced).
func (s *State) MatchersForNode(node types.NodeView) ([]matcher.Match, error) {
	return s.polMan.MatchersForNode(node)
}

// NodeCapMap returns the policy-derived CapMap for the given node, suitable
// for merging into [tailcfg.Node.CapMap] when the node is rendered as self or
// as someone else's peer.
func (s *State) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap {
	return s.polMan.NodeCapMap(id)
}

// NodeCapMaps returns a snapshot of every node's policy CapMap so
// callers can amortise lock acquisition over a peer loop.
func (s *State) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap {
	return s.polMan.NodeCapMaps()
}

// NodeCanHaveTag checks if a node is allowed to have a specific tag.
func (s *State) NodeCanHaveTag(node types.NodeView, tag string) bool {
	return s.polMan.NodeCanHaveTag(node, tag)
}

// SetPolicy updates the policy configuration.
func (s *State) SetPolicy(pol []byte) (bool, error) {
	changed, err := s.polMan.SetPolicy(pol)
	if err != nil {
		return changed, err
	}

	// Clear SSH check auth times when policy changes.
	s.ClearSSHCheckAuth()

	return changed, nil
}

// AutoApproveRoutes checks if a node's routes should be auto-approved.
// AutoApproveRoutes checks if any routes should be auto-approved for a node and updates them.
func (s *State) AutoApproveRoutes(nv types.NodeView) (change.Change, error) {
	approved, changed := policy.ApproveRoutesWithPolicy(s.polMan, nv, nv.ApprovedRoutes().AsSlice(), nv.AnnouncedRoutes())
	if changed {
		log.Debug().
			EmbedObject(nv).
			Strs("routes.announced", util.PrefixesToString(nv.AnnouncedRoutes())).
			Strs("routes.approved.old", util.PrefixesToString(nv.ApprovedRoutes().AsSlice())).
			Strs("routes.approved.new", util.PrefixesToString(approved)).
			Msg("Single node auto-approval detected route changes")

		// Persist the auto-approved routes to database and [NodeStore] via
		// [State.SetApprovedRoutes]. This ensures consistency between database
		// and [NodeStore].
		_, c, err := s.SetApprovedRoutes(nv.ID(), approved)
		if err != nil {
			log.Error().
				EmbedObject(nv).
				Err(err).
				Msg("Failed to persist auto-approved routes")

			return change.Change{}, err
		}

		log.Info().EmbedObject(nv).Strs(zf.RoutesApproved, util.PrefixesToString(approved)).Msg("routes approved")

		return c, nil
	}

	return change.Change{}, nil
}

// GetPolicy retrieves the current policy from the database.
func (s *State) GetPolicy() (*types.Policy, error) {
	return s.db.GetPolicy()
}

// SetPolicyInDB stores policy data in the database.
func (s *State) SetPolicyInDB(data string) (*types.Policy, error) {
	return s.db.SetPolicy(data)
}

// GetNodePrimaryRoutes returns the primary routes for a node.
func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix {
	return s.nodeStore.PrimaryRoutesForNode(nodeID)
}

// RoutesForPeer computes the routes a peer should advertise in a viewer's
// AllowedIPs, combining primary routes (from HA election), approved exit
// routes, and via grant steering.
//
// Approved exit routes (0.0.0.0/0, ::/0) are included alongside subnet
// routes — they appear in every peer's AllowedIPs, while unapproved
// ones do not.
func (s *State) RoutesForPeer(
	viewer, peer types.NodeView,
	matchers []matcher.Match,
) []netip.Prefix {
	viaResult := s.polMan.ViaRoutesForPeer(viewer, peer)
	globalPrimaries := s.nodeStore.PrimaryRoutesForNode(peer.ID())
	exitRoutes := peer.ExitRoutes()

	var reduced []netip.Prefix

	// Fast path: no via grants affect this pair.
	if len(viaResult.Include) == 0 && len(viaResult.Exclude) == 0 {
		allRoutes := slices.Concat(globalPrimaries, exitRoutes)

		reduced = policy.ReduceRoutes(viewer, allRoutes, matchers)
	} else {
		// Slow path: drop excluded routes, reduce, append via-included.
		routes := make([]netip.Prefix, 0, len(globalPrimaries)+len(exitRoutes))
		for _, p := range slices.Concat(globalPrimaries, exitRoutes) {
			if !slices.Contains(viaResult.Exclude, p) {
				routes = append(routes, p)
			}
		}

		reduced = policy.ReduceRoutes(viewer, routes, matchers)

		// Append via-included routes. The via grant IS the authorization
		// (no matcher filter needed), but HA primary election applies
		// when a regular (non-via) grant also covers the same prefix.
		//
		// Rules:
		//   - Peer is HA primary → always include
		//   - Peer is NOT primary, no regular grant → include
		//     (per-viewer via steering)
		//   - Peer is NOT primary, regular grant exists → exclude
		//     (HA primary wins)
		for _, p := range viaResult.Include {
			if slices.Contains(reduced, p) {
				continue
			}

			if slices.Contains(globalPrimaries, p) {
				reduced = append(reduced, p)
			} else if !slices.Contains(viaResult.UsePrimary, p) {
				reduced = append(reduced, p)
			}
		}
	}

	// Co-router visibility: when the viewer advertises the same prefix
	// that the peer is HA primary for, the viewer must see that route
	// regardless of matcher authorization. HA secondaries need this to
	// know which peer is primary for their shared prefix.
	viewerSubnets := viewer.SubnetRoutes()
	if len(viewerSubnets) > 0 {
		for _, p := range globalPrimaries {
			if slices.Contains(viewerSubnets, p) && !slices.Contains(reduced, p) {
				reduced = append(reduced, p)
			}
		}
	}

	return reduced
}

// PrimaryRoutesString renders the current prefix→primary assignment
// for diagnostics.
func (s *State) PrimaryRoutesString() string {
	return s.nodeStore.PrimaryRoutesString()
}

// IsNodeHealthy reports the HA prober's view of id. Unknown nodes
// report healthy.
func (s *State) IsNodeHealthy(id types.NodeID) bool {
	return s.nodeStore.IsNodeHealthy(id)
}

// SetNodeHealth flips the runtime health bit for one node and reports
// whether the resulting primary-route assignment changed, so the HA
// prober can decide whether to fan out a PolicyChange. true means
// healthy; false means unhealthy. An unhealthy mark is dropped when
// the node is no longer an HA candidate (offline or no approved
// routes) — between probe dispatch and result the node may have left
// candidacy, and the bit would just be stale. The check happens
// inside the writer goroutine so it serialises against the
// SetApprovedRoutes / Disconnect that removed candidacy. Single-node
// convenience wrapper around [State.BatchSetNodeHealth].
func (s *State) SetNodeHealth(id types.NodeID, healthy bool) bool {
	return s.BatchSetNodeHealth(map[types.NodeID]bool{id: healthy})
}

// BatchSetNodeHealth applies a set of health updates atomically: the
// election runs once after every flag has been flipped, so observers
// never see an intermediate snapshot. Returns true when the
// primary-route assignment differs from before the batch so callers
// can gate a single PolicyChange dispatch. Map value true = healthy;
// false = unhealthy (gated as in [State.SetNodeHealth]).
//
// Per-call publication would let a writer applying two flips
// back-to-back elect a node that the next snapshot demotes,
// momentarily pointing peers at the wrong primary; the batched form
// closes that window.
func (s *State) BatchSetNodeHealth(updates map[types.NodeID]bool) bool {
	if len(updates) == 0 {
		return false
	}

	prevRoutes := s.nodeStore.PrimaryRoutes()

	fns := make(map[types.NodeID]UpdateNodeFunc, len(updates))
	for id, healthy := range updates {
		fns[id] = healthSetter(healthy)
	}

	s.nodeStore.UpdateNodes(fns)

	return !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes())
}

// healthSetter returns an UpdateNodeFunc that flips n.Unhealthy to
// the inverse of healthy, with the same gate as [State.SetNodeHealth]:
// an unhealthy mark only sticks when the node is still online and
// still advertises approved routes, so a node that left HA candidacy
// between probe dispatch and result does not carry a stale bit.
func healthSetter(healthy bool) UpdateNodeFunc {
	return func(n *types.Node) {
		if !healthy {
			online := n.IsOnline != nil && *n.IsOnline
			if !online || len(n.AllApprovedRoutes()) == 0 {
				return
			}
		}

		n.Unhealthy = !healthy
	}
}

// ValidateAPIKey checks if an API key is valid and active.
func (s *State) ValidateAPIKey(keyStr string) (bool, error) {
	return s.db.ValidateAPIKey(keyStr)
}

// CreateAPIKey generates a new API key with optional expiration.
func (s *State) CreateAPIKey(expiration *time.Time) (string, *types.APIKey, error) {
	return s.db.CreateAPIKey(expiration)
}

// GetAPIKey retrieves an API key by its prefix.
// Accepts both display format (hskey-api-{12chars}-***) and database format ({12chars}).
func (s *State) GetAPIKey(displayPrefix string) (*types.APIKey, error) {
	// Parse the display prefix to extract the database prefix
	prefix, err := hsdb.ParseAPIKeyPrefix(displayPrefix)
	if err != nil {
		return nil, err
	}

	return s.db.GetAPIKey(prefix)
}

// GetAPIKeyByID retrieves an API key by its database ID.
func (s *State) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
	return s.db.GetAPIKeyByID(id)
}

// ExpireAPIKey marks an API key as expired.
func (s *State) ExpireAPIKey(key *types.APIKey) error {
	return s.db.ExpireAPIKey(key)
}

// ListAPIKeys returns all API keys in the system.
func (s *State) ListAPIKeys() ([]types.APIKey, error) {
	return s.db.ListAPIKeys()
}

// DestroyAPIKey permanently removes an API key.
func (s *State) DestroyAPIKey(key types.APIKey) error {
	return s.db.DestroyAPIKey(key)
}

// CreatePreAuthKey generates a new pre-authentication key for a user.
// The userID parameter is now optional (can be nil) for system-created tagged keys.
func (s *State) CreatePreAuthKey(userID *types.UserID, reusable bool, ephemeral bool, expiration *time.Time, aclTags []string) (*types.PreAuthKeyNew, error) {
	return s.db.CreatePreAuthKey(userID, reusable, ephemeral, expiration, aclTags)
}

// Test helpers for the state layer

// CreateUserForTest creates a test user. This is a convenience wrapper around the database layer.
func (s *State) CreateUserForTest(name ...string) *types.User {
	return s.db.CreateUserForTest(name...)
}

// CreateNodeForTest creates a test node. This is a convenience wrapper around the database layer.
func (s *State) CreateNodeForTest(user *types.User, hostname ...string) *types.Node {
	return s.db.CreateNodeForTest(user, hostname...)
}

// PutNodeInStoreForTest writes a test node into the in-memory [NodeStore]
// so handlers backed by [NodeStore] lookups (e.g. [State.GetNodeByID]) can
// see it. [State.CreateNodeForTest] only saves to the database, which is
// fine for tests that exercise the DB layer directly but insufficient for
// handler tests that go through [State].
func (s *State) PutNodeInStoreForTest(node types.Node) types.NodeView {
	return s.nodeStore.PutNode(node)
}

// CreateRegisteredNodeForTest creates a test node with allocated IPs. This is a convenience wrapper around the database layer.
func (s *State) CreateRegisteredNodeForTest(user *types.User, hostname ...string) *types.Node {
	return s.db.CreateRegisteredNodeForTest(user, hostname...)
}

// CreateNodesForTest creates multiple test nodes. This is a convenience wrapper around the database layer.
func (s *State) CreateNodesForTest(user *types.User, count int, namePrefix ...string) []*types.Node {
	return s.db.CreateNodesForTest(user, count, namePrefix...)
}

// CreateUsersForTest creates multiple test users. This is a convenience wrapper around the database layer.
func (s *State) CreateUsersForTest(count int, namePrefix ...string) []*types.User {
	return s.db.CreateUsersForTest(count, namePrefix...)
}

// DB returns the underlying database for testing purposes.
func (s *State) DB() *hsdb.HSDatabase {
	return s.db
}

// GetPreAuthKey retrieves a pre-authentication key by ID.
func (s *State) GetPreAuthKey(id string) (*types.PreAuthKey, error) {
	return s.db.GetPreAuthKey(id)
}

// ListPreAuthKeys returns all pre-authentication keys for a user.
func (s *State) ListPreAuthKeys() ([]types.PreAuthKey, error) {
	return s.db.ListPreAuthKeys()
}

// ExpirePreAuthKey marks a pre-authentication key as expired.
func (s *State) ExpirePreAuthKey(id uint64) error {
	return s.db.ExpirePreAuthKey(id)
}

// DeletePreAuthKey permanently deletes a pre-authentication key.
func (s *State) DeletePreAuthKey(id uint64) error {
	return s.db.DeletePreAuthKey(id)
}

// GetAuthCacheEntry retrieves a pending auth request from the cache.
func (s *State) GetAuthCacheEntry(id types.AuthID) (*types.AuthRequest, bool) {
	return s.authCache.Get(id)
}

// SetAuthCacheEntry stores a pending auth request in the cache.
func (s *State) SetAuthCacheEntry(id types.AuthID, entry *types.AuthRequest) {
	s.authCache.Add(id, entry)
}

// SetLastSSHAuth records a successful SSH check authentication
// for the given (src, dst) node pair.
func (s *State) SetLastSSHAuth(src, dst types.NodeID) {
	s.sshCheckMu.Lock()
	defer s.sshCheckMu.Unlock()

	s.sshCheckAuth[sshCheckPair{Src: src, Dst: dst}] = time.Now()
}

// GetLastSSHAuth returns when src last authenticated for SSH check
// to dst.
func (s *State) GetLastSSHAuth(src, dst types.NodeID) (time.Time, bool) {
	s.sshCheckMu.RLock()
	defer s.sshCheckMu.RUnlock()

	t, ok := s.sshCheckAuth[sshCheckPair{Src: src, Dst: dst}]

	return t, ok
}

// ClearSSHCheckAuth clears all recorded SSH check auth times.
// Called when the policy changes to ensure stale auth times don't grant access.
func (s *State) ClearSSHCheckAuth() {
	s.sshCheckMu.Lock()
	defer s.sshCheckMu.Unlock()

	s.sshCheckAuth = make(map[sshCheckPair]time.Time)
}

// preserveNetInfo preserves NetInfo from an existing node for faster DERP connectivity.
// If no existing node is provided, it creates new netinfo from the provided hostinfo.
func preserveNetInfo(existingNode types.NodeView, nodeID types.NodeID, validHostinfo *tailcfg.Hostinfo) *tailcfg.NetInfo {
	var existingHostinfo *tailcfg.Hostinfo
	if existingNode.Valid() {
		existingHostinfo = existingNode.Hostinfo().AsStruct()
	}

	return netInfoFromMapRequest(nodeID, existingHostinfo, validHostinfo)
}

// newNodeParams contains parameters for creating a new node.
type newNodeParams struct {
	User           types.User
	MachineKey     key.MachinePublic
	NodeKey        key.NodePublic
	DiscoKey       key.DiscoPublic
	Hostname       string
	Hostinfo       *tailcfg.Hostinfo
	Endpoints      []netip.AddrPort
	Expiry         *time.Time
	RegisterMethod string

	// Optional: Pre-auth key specific fields
	PreAuthKey *types.PreAuthKey

	// Optional: Existing node for netinfo preservation
	ExistingNodeForNetinfo types.NodeView
}

// authNodeUpdateParams contains parameters for updating an existing node during auth.
type authNodeUpdateParams struct {
	// Node to update; must be valid and in [NodeStore].
	ExistingNode types.NodeView
	// Cached registration payload from the originating client request.
	RegData *types.RegistrationData
	// Pre-validated hostinfo; NetInfo preserved from ExistingNode.
	ValidHostinfo *tailcfg.Hostinfo
	// Hostname from hostinfo, or generated from keys if client omits it.
	Hostname string
	// Auth user; may differ from ExistingNode.User() on conversion.
	User *types.User
	// Overrides RegData.Expiry; ignored for tagged nodes.
	Expiry *time.Time
	// Only used when IsConvertFromTag=true.
	RegisterMethod string
	// Set true for tagged->user conversion. Affects RegisterMethod and expiry.
	IsConvertFromTag bool
}

// applyAuthNodeUpdate applies common update logic for re-authenticating or converting
// an existing node. It updates the node in [NodeStore], processes RequestTags, and
// persists changes to the database.
func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView, error) {
	regData := params.RegData
	// Log the operation type
	if params.IsConvertFromTag {
		log.Info().
			EmbedObject(params.ExistingNode).
			Strs("old.tags", params.ExistingNode.Tags().AsSlice()).
			Msg("Converting tagged node to user-owned node")
	} else {
		log.Info().
			Object("existing", params.ExistingNode).
			Str("incoming.hostname", regData.Hostname).
			Str("incoming.machine_key", regData.MachineKey.ShortString()).
			Msg("Updating existing node registration via reauth")
	}

	// Process RequestTags during reauth (#2979).
	// Due to json:",omitempty", empty/nil from the cached Hostinfo
	// means "clear tags".
	var requestTags []string
	if regData.Hostinfo != nil {
		requestTags = regData.Hostinfo.RequestTags
	}

	oldTags := params.ExistingNode.Tags().AsSlice()

	// Validate tags BEFORE calling [NodeStore.UpdateNode] to ensure we don't modify
	// [NodeStore] if validation fails. This maintains consistency between [NodeStore]
	// and database.
	rejectedTags := s.validateRequestTags(params.ExistingNode, requestTags)
	if len(rejectedTags) > 0 {
		return types.NodeView{}, fmt.Errorf(
			"%w %v are invalid or not permitted",
			ErrRequestedTagsInvalidOrNotPermitted,
			rejectedTags,
		)
	}

	// Update existing node in [NodeStore] - validation passed, safe to mutate
	updatedNodeView, ok := s.nodeStore.UpdateNode(params.ExistingNode.ID(), func(node *types.Node) {
		node.NodeKey = regData.NodeKey
		node.DiscoKey = regData.DiscoKey
		node.Hostname = params.Hostname

		// Preserve NetInfo from existing node when re-registering
		node.Hostinfo = params.ValidHostinfo
		node.Hostinfo.NetInfo = preserveNetInfo(
			params.ExistingNode,
			params.ExistingNode.ID(),
			params.ValidHostinfo,
		)

		node.Endpoints = regData.Endpoints
		// Do NOT reset IsOnline here. Online status is managed exclusively by
		// [State.Connect]/[State.Disconnect] in the poll session lifecycle.
		// Resetting it during re-registration causes a false offline blip: the
		// change notification triggers a map regeneration showing the node as
		// offline to peers, even though [State.Connect] will immediately set it
		// back to true.
		node.LastSeen = new(time.Now())

		// On conversion (tagged → user) we set the new register method.
		// On plain reauth we preserve the existing node.RegisterMethod;
		// the cached RegistrationData no longer carries it because the
		// producer never populated it.
		if params.IsConvertFromTag {
			node.RegisterMethod = params.RegisterMethod
		}

		// Track tagged status BEFORE processing tags
		wasTagged := node.IsTagged()

		// Process tags - may change node.Tags and node.UserID
		// Tags were pre-validated, so this will always succeed (no rejected tags)
		_ = s.processReauthTags(node, requestTags, params.User, oldTags)

		// Handle expiry AFTER tag processing, based on transition
		// This ensures expiry is correctly set/cleared based on the NEW tagged status
		isTagged := node.IsTagged()

		switch {
		case wasTagged && !isTagged:
			// Tagged → Personal: set expiry from client request
			if params.Expiry != nil {
				node.Expiry = params.Expiry
			} else {
				node.Expiry = regData.Expiry
			}
		case !wasTagged && isTagged:
			// Personal → Tagged: clear expiry (tagged nodes don't expire)
			node.Expiry = nil
		case params.IsConvertFromTag:
			// Explicit conversion from tagged to user-owned: set expiry from client request
			if params.Expiry != nil {
				node.Expiry = params.Expiry
			} else {
				node.Expiry = regData.Expiry
			}
		case !isTagged:
			// Personal → Personal: update expiry from client
			if params.Expiry != nil {
				node.Expiry = params.Expiry
			} else {
				node.Expiry = regData.Expiry
			}
		}
		// Tagged → Tagged: keep existing expiry (nil) - no action needed

		// Apply default node expiry for non-tagged nodes when the
		// resolved expiry is still nil or zero (e.g., CLI registration
		// where the client did not request a specific expiry).
		needsDefaultExpiry := !node.IsTagged() &&
			(node.Expiry == nil || node.Expiry.IsZero()) &&
			s.cfg.Node.Expiry > 0
		if needsDefaultExpiry {
			exp := time.Now().Add(s.cfg.Node.Expiry)
			node.Expiry = &exp
		}
	})

	if !ok {
		return types.NodeView{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, params.ExistingNode.ID())
	}

	// Persist to database.
	// Explicitly select all node columns so GORM includes nil/zero-value fields
	// (see nodeUpdateColumns comment).
	_, err := hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
		err := tx.Select(nodeUpdateColumns).Updates(updatedNodeView.AsStruct()).Error
		if err != nil {
			return nil, fmt.Errorf("saving node: %w", err)
		}

		return nil, nil //nolint:nilnil // side-effect only write
	})
	if err != nil {
		return types.NodeView{}, err
	}

	// Log completion
	if params.IsConvertFromTag {
		log.Trace().
			EmbedObject(updatedNodeView).
			Msg("Tagged node converted to user-owned")
	} else {
		log.Trace().
			EmbedObject(updatedNodeView).
			Msg("Node re-authorized")
	}

	return updatedNodeView, nil
}

// createAndSaveNewNode creates a new node, allocates IPs, saves to DB, and adds to [NodeStore].
// It preserves netinfo from an existing node if one is provided (for faster DERP connectivity).
func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, error) {
	// Preserve NetInfo from existing node if available
	if params.Hostinfo != nil {
		params.Hostinfo.NetInfo = preserveNetInfo(
			params.ExistingNodeForNetinfo,
			types.NodeID(0),
			params.Hostinfo,
		)
	}

	// Prepare the node for registration
	nodeToRegister := types.Node{
		Hostname:       params.Hostname,
		MachineKey:     params.MachineKey,
		NodeKey:        params.NodeKey,
		DiscoKey:       params.DiscoKey,
		Hostinfo:       params.Hostinfo,
		Endpoints:      params.Endpoints,
		LastSeen:       new(time.Now()),
		IsOnline:       new(false), // Explicitly offline until [State.Connect] is called
		RegisterMethod: params.RegisterMethod,
		Expiry:         params.Expiry,
	}

	// Assign ownership based on PreAuthKey
	if params.PreAuthKey != nil {
		if params.PreAuthKey.IsTagged() {
			// Tagged nodes are owned by their tags, not a user.
			// UserID is intentionally left nil.
			nodeToRegister.Tags = params.PreAuthKey.Proto().GetAclTags()

			// Tagged nodes have key expiry disabled.
			nodeToRegister.Expiry = nil
		} else {
			// USER-OWNED NODE
			nodeToRegister.UserID = &params.PreAuthKey.User.ID
			nodeToRegister.User = params.PreAuthKey.User
			nodeToRegister.Tags = nil
		}

		nodeToRegister.AuthKey = params.PreAuthKey
		nodeToRegister.AuthKeyID = &params.PreAuthKey.ID
	} else {
		// Non-PreAuthKey registration (OIDC, CLI) - always user-owned
		nodeToRegister.UserID = &params.User.ID
		nodeToRegister.User = &params.User
		nodeToRegister.Tags = nil
	}

	// Reject advertise-tags for PreAuthKey registrations early, before any resource allocation.
	// PreAuthKey nodes get their tags from the key itself, not from client requests.
	if params.PreAuthKey != nil && params.Hostinfo != nil && len(params.Hostinfo.RequestTags) > 0 {
		return types.NodeView{}, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, params.Hostinfo.RequestTags)
	}

	// Process RequestTags (from tailscale up --advertise-tags) ONLY for non-PreAuthKey registrations.
	// Validate early before IP allocation to avoid resource leaks on failure.
	if params.PreAuthKey == nil && params.Hostinfo != nil && len(params.Hostinfo.RequestTags) > 0 {
		// Validate all tags before applying - reject if any tag is not permitted
		rejectedTags := s.validateRequestTags(nodeToRegister.View(), params.Hostinfo.RequestTags)
		if len(rejectedTags) > 0 {
			return types.NodeView{}, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, rejectedTags)
		}

		// All tags are approved - apply them
		approvedTags := params.Hostinfo.RequestTags
		if len(approvedTags) > 0 {
			nodeToRegister.Tags = approvedTags
			slices.Sort(nodeToRegister.Tags)
			nodeToRegister.Tags = slices.Compact(nodeToRegister.Tags)

			// Node is now tagged, so clear user ownership.
			// Tagged nodes are owned by their tags, not a user.
			nodeToRegister.UserID = nil
			nodeToRegister.User = nil

			// Tagged nodes have key expiry disabled.
			nodeToRegister.Expiry = nil

			log.Info().
				Str(zf.NodeName, nodeToRegister.Hostname).
				Strs(zf.NodeTags, nodeToRegister.Tags).
				Msg("approved advertise-tags during registration")
		}
	}

	// Apply default node expiry for non-tagged nodes when the client
	// did not request a specific expiry.
	// Tagged nodes are exempt — they never expire.
	needsDefaultExpiry := !nodeToRegister.IsTagged() &&
		(nodeToRegister.Expiry == nil || nodeToRegister.Expiry.IsZero()) &&
		s.cfg.Node.Expiry > 0
	if needsDefaultExpiry {
		exp := time.Now().Add(s.cfg.Node.Expiry)
		nodeToRegister.Expiry = &exp
	}

	// Validate before saving
	err := validateNodeOwnership(&nodeToRegister)
	if err != nil {
		return types.NodeView{}, err
	}

	// Allocate new IPs
	ipv4, ipv6, err := s.ipAlloc.Next()
	if err != nil {
		return types.NodeView{}, fmt.Errorf("allocating IPs: %w", err)
	}

	nodeToRegister.IPv4 = ipv4
	nodeToRegister.IPv6 = ipv6

	// Seed GivenName from the sanitised raw hostname. [NodeStore.PutNode]
	// bumps on collision and falls back to "node" if the sanitised
	// result is empty (pure non-ASCII / punctuation input).
	if nodeToRegister.GivenName == "" {
		nodeToRegister.GivenName = dnsname.SanitizeHostname(nodeToRegister.Hostname)
	}

	// New node - database first to get ID, then [NodeStore]
	savedNode, err := hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
		err := tx.Save(&nodeToRegister).Error
		if err != nil {
			return nil, fmt.Errorf("saving node: %w", err)
		}

		if params.PreAuthKey != nil && !params.PreAuthKey.Reusable {
			err := hsdb.UsePreAuthKey(tx, params.PreAuthKey)
			if err != nil {
				return nil, fmt.Errorf("using pre auth key: %w", err)
			}
		}

		return &nodeToRegister, nil
	})
	if err != nil {
		return types.NodeView{}, err
	}

	// Add to [NodeStore] after database creates the ID
	return s.nodeStore.PutNode(*savedNode), nil
}

// validateRequestTags validates that the requested tags are permitted for the node.
// This should be called BEFORE [NodeStore.UpdateNode] to ensure we don't modify [NodeStore]
// if validation fails. Returns the list of rejected tags (empty if all valid).
func (s *State) validateRequestTags(node types.NodeView, requestTags []string) []string {
	// Empty tags = clear tags, always permitted
	if len(requestTags) == 0 {
		return nil
	}

	var rejectedTags []string

	for _, tag := range requestTags {
		if !s.polMan.NodeCanHaveTag(node, tag) {
			rejectedTags = append(rejectedTags, tag)
		}
	}

	return rejectedTags
}

// processReauthTags handles tag changes during node re-authentication.
// It processes RequestTags from the client and updates node tags accordingly.
// Returns rejected tags (if any) for post-validation error handling.
func (s *State) processReauthTags(
	node *types.Node,
	requestTags []string,
	user *types.User,
	oldTags []string,
) []string {
	wasAuthKeyTagged := node.AuthKey != nil && node.AuthKey.IsTagged()

	logEvent := log.Debug().
		Uint64(zf.NodeID, uint64(node.ID)).
		Str(zf.NodeName, node.Hostname).
		Strs(zf.RequestTags, requestTags).
		Strs(zf.CurrentTags, node.Tags).
		Bool(zf.IsTagged, node.IsTagged()).
		Bool(zf.WasAuthKeyTagged, wasAuthKeyTagged)
	logEvent.Msg("processing RequestTags during reauth")

	// Empty RequestTags means untag node (transition to user-owned)
	if len(requestTags) == 0 {
		if node.IsTagged() {
			log.Info().
				Uint64(zf.NodeID, uint64(node.ID)).
				Str(zf.NodeName, node.Hostname).
				Strs(zf.RemovedTags, node.Tags).
				Str(zf.UserName, user.Name).
				Bool(zf.WasAuthKeyTagged, wasAuthKeyTagged).
				Msg("Reauth: removing all tags, returning node ownership to user")

			node.Tags = []string{}
			node.UserID = &user.ID
			node.User = user
		}

		return nil
	}

	// Non-empty RequestTags: validate and apply
	var approvedTags, rejectedTags []string

	for _, tag := range requestTags {
		if s.polMan.NodeCanHaveTag(node.View(), tag) {
			approvedTags = append(approvedTags, tag)
		} else {
			rejectedTags = append(rejectedTags, tag)
		}
	}

	if len(rejectedTags) > 0 {
		log.Warn().
			Uint64(zf.NodeID, uint64(node.ID)).
			Str(zf.NodeName, node.Hostname).
			Strs(zf.RejectedTags, rejectedTags).
			Msg("Reauth: requested tags are not permitted")

		return rejectedTags
	}

	if len(approvedTags) > 0 {
		slices.Sort(approvedTags)
		approvedTags = slices.Compact(approvedTags)

		wasTagged := node.IsTagged()
		node.Tags = approvedTags

		// Tagged nodes are owned by their tags, not a user.
		node.UserID = nil
		node.User = nil

		if !wasTagged {
			log.Info().
				Uint64(zf.NodeID, uint64(node.ID)).
				Str(zf.NodeName, node.Hostname).
				Strs(zf.NewTags, approvedTags).
				Str(zf.OldUser, user.Name).
				Msg("Reauth: applying tags, transferring node to tagged-devices")
		} else {
			log.Info().
				Uint64(zf.NodeID, uint64(node.ID)).
				Str(zf.NodeName, node.Hostname).
				Strs(zf.OldTags, oldTags).
				Strs(zf.NewTags, approvedTags).
				Msg("Reauth: updating tags on already-tagged node")
		}
	}

	return nil
}

// HandleNodeFromAuthPath handles node registration through authentication flow (like OIDC).
func (s *State) HandleNodeFromAuthPath(
	authID types.AuthID,
	userID types.UserID,
	expiry *time.Time,
	registrationMethod string,
) (types.NodeView, change.Change, error) {
	// Get the registration entry from cache
	regEntry, ok := s.GetAuthCacheEntry(authID)
	if !ok {
		return types.NodeView{}, change.Change{}, hsdb.ErrNodeNotFoundRegistrationCache
	}

	// Get the user
	user, err := s.db.GetUserByID(userID)
	if err != nil {
		return types.NodeView{}, change.Change{}, fmt.Errorf("finding user: %w", err)
	}

	regData := regEntry.RegistrationData()

	// Hostname was already validated/normalised at producer time. Build
	// the initial Hostinfo from the cached client-supplied Hostinfo (or
	// an empty stub if the client did not send one).
	hostname := regData.Hostname

	hostinfo := &tailcfg.Hostinfo{}
	if regData.Hostinfo != nil {
		hostinfo = regData.Hostinfo.Clone()
	}

	hostinfo.Hostname = hostname

	// Lookup existing nodes
	machineKey := regData.MachineKey
	existingNodeSameUser, _ := s.nodeStore.GetNodeByMachineKey(machineKey, types.UserID(user.ID))
	existingNodeAnyUser, _ := s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey)

	// Named conditions - describe WHAT we found, not HOW we check it
	nodeExistsForSameUser := existingNodeSameUser.Valid()
	nodeExistsForAnyUser := existingNodeAnyUser.Valid()
	existingNodeIsTagged := nodeExistsForAnyUser && existingNodeAnyUser.IsTagged()
	existingNodeOwnedByOtherUser := nodeExistsForAnyUser &&
		!existingNodeIsTagged &&
		existingNodeAnyUser.UserID().Get() != user.ID

	// Create logger with common fields for all auth operations
	logger := log.With().
		Str(zf.RegistrationID, authID.String()).
		Str(zf.UserName, user.Name).
		Str(zf.MachineKey, machineKey.ShortString()).
		Str(zf.Method, registrationMethod).
		Logger()

	// Common params for update operations
	updateParams := authNodeUpdateParams{
		RegData:        regData,
		ValidHostinfo:  hostinfo,
		Hostname:       hostname,
		User:           user,
		Expiry:         expiry,
		RegisterMethod: registrationMethod,
	}

	var finalNode types.NodeView

	if nodeExistsForSameUser {
		updateParams.ExistingNode = existingNodeSameUser

		finalNode, err = s.applyAuthNodeUpdate(updateParams)
		if err != nil {
			return types.NodeView{}, change.Change{}, err
		}
	} else if existingNodeIsTagged {
		updateParams.ExistingNode = existingNodeAnyUser
		updateParams.IsConvertFromTag = true

		finalNode, err = s.applyAuthNodeUpdate(updateParams)
		if err != nil {
			return types.NodeView{}, change.Change{}, err
		}
	} else if existingNodeOwnedByOtherUser {
		oldUser := existingNodeAnyUser.User()

		oldUserName := ""
		if oldUser.Valid() {
			oldUserName = oldUser.Name()
		}

		logger.Info().
			Str(zf.ExistingNodeName, existingNodeAnyUser.Hostname()).
			Uint64(zf.ExistingNodeID, existingNodeAnyUser.ID().Uint64()).
			Str(zf.OldUser, oldUserName).
			Msg("Creating new node for different user (same machine key exists for another user)")

		finalNode, err = s.createNewNodeFromAuth(
			logger, user, regData, hostname, hostinfo,
			expiry, registrationMethod, existingNodeAnyUser,
		)
		if err != nil {
			return types.NodeView{}, change.Change{}, err
		}
	} else {
		finalNode, err = s.createNewNodeFromAuth(
			logger, user, regData, hostname, hostinfo,
			expiry, registrationMethod, types.NodeView{},
		)
		if err != nil {
			return types.NodeView{}, change.Change{}, err
		}
	}

	// Signal to waiting clients
	regEntry.FinishAuth(types.AuthVerdict{Node: finalNode})

	// Remove from registration cache
	s.authCache.Remove(authID)

	// Update policy managers
	usersChange, err := s.updatePolicyManagerUsers()
	if err != nil {
		return finalNode, change.NodeAdded(finalNode.ID()), fmt.Errorf("updating policy manager users: %w", err)
	}

	nodesChange, err := s.updatePolicyManagerNodes()
	if err != nil {
		return finalNode, change.NodeAdded(finalNode.ID()), fmt.Errorf("updating policy manager nodes: %w", err)
	}

	var c change.Change
	if !usersChange.IsEmpty() || !nodesChange.IsEmpty() {
		c = change.PolicyChange()
	} else {
		c = change.NodeAdded(finalNode.ID())
	}

	return finalNode, c, nil
}

// createNewNodeFromAuth creates a new node during auth callback.
// This is used for both new registrations and when a machine already has a node
// for a different user.
func (s *State) createNewNodeFromAuth(
	logger zerolog.Logger,
	user *types.User,
	regData *types.RegistrationData,
	hostname string,
	validHostinfo *tailcfg.Hostinfo,
	expiry *time.Time,
	registrationMethod string,
	existingNodeForNetinfo types.NodeView,
) (types.NodeView, error) {
	logger.Debug().
		Interface("expiry", expiry).
		Msg("Registering new node from auth callback")

	return s.createAndSaveNewNode(newNodeParams{
		User:                   *user,
		MachineKey:             regData.MachineKey,
		NodeKey:                regData.NodeKey,
		DiscoKey:               regData.DiscoKey,
		Hostname:               hostname,
		Hostinfo:               validHostinfo,
		Endpoints:              regData.Endpoints,
		Expiry:                 cmp.Or(expiry, regData.Expiry),
		RegisterMethod:         registrationMethod,
		ExistingNodeForNetinfo: existingNodeForNetinfo,
	})
}

// HandleNodeFromPreAuthKey handles node registration using a pre-authentication key.
// findExistingNodeForPAK looks up an existing node by machine key,
// matching the PAK's ownership. For user-owned keys it checks the
// user's ID; for tagged keys it checks UserID(0) since tagged nodes
// have no owning user.
func (s *State) findExistingNodeForPAK(
	machineKey key.MachinePublic,
	pak *types.PreAuthKey,
) (types.NodeView, bool) {
	if pak.User != nil {
		node, exists := s.nodeStore.GetNodeByMachineKey(machineKey, types.UserID(pak.User.ID))
		if exists {
			return node, true
		}
	}

	// Tagged nodes have nil UserID, so they are indexed under UserID(0)
	// in nodesByMachineKey. Check there for tagged PAK re-registration.
	if pak.IsTagged() {
		return s.nodeStore.GetNodeByMachineKey(machineKey, 0)
	}

	return types.NodeView{}, false
}

//nolint:gocyclo // sequential validation/update/create paths with security-sensitive ordering
func (s *State) HandleNodeFromPreAuthKey(
	regReq tailcfg.RegisterRequest,
	machineKey key.MachinePublic,
) (types.NodeView, change.Change, error) {
	pak, err := s.GetPreAuthKey(regReq.Auth.AuthKey)
	if err != nil {
		return types.NodeView{}, change.Change{}, err
	}

	// Helper to get username for logging (handles nil User for tags-only keys)
	pakUsername := func() string {
		if pak.User != nil {
			return pak.User.Username()
		}

		return types.TaggedDevices.Name
	}

	existingNodeSameUser, existsSameUser := s.findExistingNodeForPAK(machineKey, pak)

	// For existing nodes, skip validation if:
	// 1. MachineKey matches (cryptographic proof of machine identity)
	// 2. User/tag ownership matches (from the PAK being used)
	// 3. Not a NodeKey rotation (rotation requires fresh validation)
	//
	// Security: MachineKey is the cryptographic identity. If someone has the MachineKey,
	// they control the machine. The PAK was only needed to authorize initial join.
	// We don't check which specific PAK was used originally because:
	// - Container restarts may use different PAKs (e.g., env var changed)
	// - Original PAK may be deleted
	// - MachineKey + ownership is sufficient to prove this is the same node
	isExistingNodeReregistering := existsSameUser && existingNodeSameUser.Valid()

	// Check if this is a NodeKey rotation (different NodeKey)
	isNodeKeyRotation := existsSameUser && existingNodeSameUser.Valid() &&
		existingNodeSameUser.NodeKey() != regReq.NodeKey

	if isExistingNodeReregistering && !isNodeKeyRotation {
		// Existing node re-registering with same NodeKey: skip validation.
		// Pre-auth keys are only needed for initial authentication. Critical for
		// containers that run "tailscale up --authkey=KEY" on every restart.
		log.Debug().
			Caller().
			Uint64(zf.NodeID, existingNodeSameUser.ID().Uint64()).
			Str(zf.NodeName, existingNodeSameUser.Hostname()).
			Str(zf.MachineKey, machineKey.ShortString()).
			Str(zf.NodeKeyExisting, existingNodeSameUser.NodeKey().ShortString()).
			Str(zf.NodeKeyRequest, regReq.NodeKey.ShortString()).
			Uint64(zf.AuthKeyID, pak.ID).
			Bool(zf.AuthKeyUsed, pak.Used).
			Bool(zf.AuthKeyExpired, pak.Expiration != nil && pak.Expiration.Before(time.Now())).
			Bool(zf.AuthKeyReusable, pak.Reusable).
			Bool(zf.NodeKeyRotation, isNodeKeyRotation).
			Msg("Existing node re-registering with same NodeKey and auth key, skipping validation")
	} else {
		// New node or NodeKey rotation: require valid auth key.
		err = pak.Validate()
		if err != nil {
			return types.NodeView{}, change.Change{}, err
		}
	}

	// Preserve the raw hostname as reported by the client. Sanitisation
	// for the DNS label lives on node.GivenName, not on node.Hostname;
	// see HOSTNAME.md.
	var hostname string
	if regReq.Hostinfo != nil {
		hostname = regReq.Hostinfo.Hostname
	}

	// Ensure we have valid hostinfo
	validHostinfo := cmp.Or(regReq.Hostinfo, &tailcfg.Hostinfo{})
	validHostinfo.Hostname = hostname

	log.Debug().
		Caller().
		Str(zf.NodeName, hostname).
		Str(zf.MachineKey, machineKey.ShortString()).
		Str(zf.NodeKey, regReq.NodeKey.ShortString()).
		Str(zf.UserName, pakUsername()).
		Msg("Registering node with pre-auth key")

	var finalNode types.NodeView

	// If this node exists for this user, update the node in place.
	// Note: For tags-only keys (pak.User == nil), existsSameUser is always false.
	if existsSameUser && existingNodeSameUser.Valid() {
		log.Trace().
			Caller().
			Str(zf.NodeName, existingNodeSameUser.Hostname()).
			Uint64(zf.NodeID, existingNodeSameUser.ID().Uint64()).
			Str(zf.MachineKey, machineKey.ShortString()).
			Str(zf.NodeKey, existingNodeSameUser.NodeKey().ShortString()).
			Str(zf.UserName, pakUsername()).
			Msg("Node re-registering with existing machine key and user, updating in place")

		// Update existing node - NodeStore first, then database
		updatedNodeView, ok := s.nodeStore.UpdateNode(existingNodeSameUser.ID(), func(node *types.Node) {
			node.NodeKey = regReq.NodeKey
			node.Hostname = hostname

			// TODO(kradalby): We should ensure we use the same hostinfo and node merge semantics
			// when a node re-registers as we do when it sends a map request (UpdateNodeFromMapRequest).

			// Preserve NetInfo from existing node when re-registering
			node.Hostinfo = validHostinfo
			node.Hostinfo.NetInfo = preserveNetInfo(existingNodeSameUser, existingNodeSameUser.ID(), validHostinfo)

			node.RegisterMethod = util.RegisterMethodAuthKey

			// Tags from PreAuthKey are only applied during initial registration.
			// On re-registration the node keeps its existing tags and ownership.
			// Only update AuthKey reference.
			node.AuthKey = pak
			node.AuthKeyID = &pak.ID
			// Do NOT reset IsOnline here. Online status is managed exclusively by
			// [State.Connect]/[State.Disconnect] in the poll session lifecycle.
			// Resetting it during re-registration causes a false offline blip
			// to peers.
			node.LastSeen = new(time.Now())

			// Tagged nodes keep their existing expiry (disabled).
			// User-owned nodes update expiry from the client request,
			// falling back to the configured default if the client
			// did not request a specific expiry. If neither is set,
			// clear the expiry so the database holds NULL instead of
			// a pointer to zero time.
			if !node.IsTagged() {
				if !regReq.Expiry.IsZero() {
					node.Expiry = &regReq.Expiry
				} else if s.cfg.Node.Expiry > 0 {
					exp := time.Now().Add(s.cfg.Node.Expiry)
					node.Expiry = &exp
				} else {
					node.Expiry = nil
				}
			}
		})

		if !ok {
			return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, existingNodeSameUser.ID())
		}

		_, err = hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
			// Explicitly select all node columns so GORM includes nil/zero-value fields
			// (see nodeUpdateColumns comment).
			err := tx.Select(nodeUpdateColumns).Updates(updatedNodeView.AsStruct()).Error
			if err != nil {
				return nil, fmt.Errorf("saving node: %w", err)
			}

			// Only mark the key used on the *first* registration. On
			// re-registration the same key is already used and the
			// atomic compare-and-set in [hsdb.UsePreAuthKey] would otherwise
			// reject it as "authkey already used". This is the path
			// behind issue #2830 where containers restart with the
			// same one-shot key.
			if !pak.Reusable && !pak.Used {
				err = hsdb.UsePreAuthKey(tx, pak)
				if err != nil {
					return nil, fmt.Errorf("using pre auth key: %w", err)
				}
			}

			return nil, nil //nolint:nilnil // intentional: transaction success
		})
		if err != nil {
			return types.NodeView{}, change.Change{}, fmt.Errorf("writing node to database: %w", err)
		}

		log.Trace().
			Caller().
			Str(zf.NodeName, updatedNodeView.Hostname()).
			Uint64(zf.NodeID, updatedNodeView.ID().Uint64()).
			Str(zf.MachineKey, machineKey.ShortString()).
			Str(zf.NodeKey, updatedNodeView.NodeKey().ShortString()).
			Str(zf.UserName, pakUsername()).
			Msg("Node re-authorized")

		finalNode = updatedNodeView
	} else {
		// Node does not exist for this user with this machine key
		// Check if node exists with this machine key for a different user
		existingNodeAnyUser, existsAnyUser := s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey)

		// For user-owned keys, check if node exists for a different user.
		// Tags-only keys (pak.User == nil) skip this check.
		// Tagged nodes are also skipped since they have no owning user.
		existingIsUserOwned := existsAnyUser &&
			existingNodeAnyUser.Valid() &&
			!existingNodeAnyUser.IsTagged()
		belongsToDifferentUser := pak.User != nil &&
			existingIsUserOwned &&
			existingNodeAnyUser.UserID().Get() != pak.User.ID

		if belongsToDifferentUser {
			// Node exists but belongs to a different user.
			// Create a new node for the new user (do not transfer).
			oldUser := existingNodeAnyUser.User()

			oldUserName := ""
			if oldUser.Valid() {
				oldUserName = oldUser.Name()
			}

			log.Info().
				Caller().
				Str(zf.ExistingNodeName, existingNodeAnyUser.Hostname()).
				Uint64(zf.ExistingNodeID, existingNodeAnyUser.ID().Uint64()).
				Str(zf.MachineKey, machineKey.ShortString()).
				Str(zf.OldUser, oldUserName).
				Str(zf.NewUser, pakUsername()).
				Msg("Creating new node for different user (same machine key exists for another user)")
		}

		// This is a new node - create it
		// For user-owned keys: create for the user
		// For tags-only keys: create as tagged node (createAndSaveNewNode handles this via PreAuthKey)

		// Create and save new node
		// Note: For tags-only keys, User is empty but createAndSaveNewNode uses PreAuthKey for ownership
		var pakUser types.User
		if pak.User != nil {
			pakUser = *pak.User
		}

		// Only pass the client-requested expiry when it is actually set.
		// A pointer to a zero time.Time gets persisted as "0001-01-01 00:00:00"
		// rather than NULL, which breaks downstream consumers that distinguish
		// "no expiry" from "expires at year 1".
		var reqExpiry *time.Time
		if !regReq.Expiry.IsZero() {
			reqExpiry = &regReq.Expiry
		}

		var err error

		finalNode, err = s.createAndSaveNewNode(newNodeParams{
			User:                   pakUser,
			MachineKey:             machineKey,
			NodeKey:                regReq.NodeKey,
			DiscoKey:               key.DiscoPublic{}, // DiscoKey not available in RegisterRequest
			Hostname:               hostname,
			Hostinfo:               validHostinfo,
			Endpoints:              nil, // Endpoints not available in RegisterRequest
			Expiry:                 reqExpiry,
			RegisterMethod:         util.RegisterMethodAuthKey,
			PreAuthKey:             pak,
			ExistingNodeForNetinfo: cmp.Or(existingNodeAnyUser, types.NodeView{}),
		})
		if err != nil {
			return types.NodeView{}, change.Change{}, fmt.Errorf("creating new node: %w", err)
		}
	}

	// Update policy managers
	usersChange, err := s.updatePolicyManagerUsers()
	if err != nil {
		return finalNode, change.NodeAdded(finalNode.ID()), fmt.Errorf("updating policy manager users: %w", err)
	}

	nodesChange, err := s.updatePolicyManagerNodes()
	if err != nil {
		return finalNode, change.NodeAdded(finalNode.ID()), fmt.Errorf("updating policy manager nodes: %w", err)
	}

	var c change.Change
	if !usersChange.IsEmpty() || !nodesChange.IsEmpty() {
		c = change.PolicyChange()
	} else {
		c = change.NodeAdded(finalNode.ID())
	}

	return finalNode, c, nil
}

// updatePolicyManagerUsers updates the policy manager with current users.
// Returns true if the policy changed and notifications should be sent.
// TODO(kradalby): This is a temporary stepping stone, ultimately we should
// have the list already available so it could go much quicker. Alternatively
// the policy manager could have a remove or add list for users.
// updatePolicyManagerUsers refreshes the policy manager with current user data.
func (s *State) updatePolicyManagerUsers() (change.Change, error) {
	users, err := s.ListAllUsers()
	if err != nil {
		return change.Change{}, fmt.Errorf("listing users for policy update: %w", err)
	}

	log.Debug().Caller().Int("user.count", len(users)).Msg("policy manager user update initiated because user list modification detected")

	changed, err := s.polMan.SetUsers(users)
	if err != nil {
		return change.Change{}, fmt.Errorf("updating policy manager users: %w", err)
	}

	log.Debug().Caller().Bool("policy.changed", changed).Msg("policy manager user update completed because SetUsers operation finished")

	if changed {
		return change.PolicyChange(), nil
	}

	return change.Change{}, nil
}

// UpdatePolicyManagerUsersForTest updates the policy manager's user cache.
// This is exposed for testing purposes to sync the policy manager after
// creating test users via CreateUserForTest().
func (s *State) UpdatePolicyManagerUsersForTest() error {
	_, err := s.updatePolicyManagerUsers()
	return err
}

// updatePolicyManagerNodes updates the policy manager with current nodes.
// Returns true if the policy changed and notifications should be sent.
// TODO(kradalby): This is a temporary stepping stone, ultimately we should
// have the list already available so it could go much quicker. Alternatively
// the policy manager could have a remove or add list for nodes.
// updatePolicyManagerNodes refreshes the policy manager with current node data.
func (s *State) updatePolicyManagerNodes() (change.Change, error) {
	nodes := s.ListNodes()

	changed, err := s.polMan.SetNodes(nodes)
	if err != nil {
		return change.Change{}, fmt.Errorf("updating policy manager nodes: %w", err)
	}

	if changed {
		// Rebuild peer maps because policy-affecting node changes (tags, user, IPs)
		// affect ACL visibility. Without this, cached peer relationships use stale data.
		s.nodeStore.RebuildPeerMaps()
		return change.PolicyChange(), nil
	}

	return change.Change{}, nil
}

// PingDB checks if the database connection is healthy.
func (s *State) PingDB(ctx context.Context) error {
	return s.db.PingDB(ctx)
}

// autoApproveNodes mass approves routes on all nodes. It is _only_ intended for
// use when the policy is replaced. It is not sending or reporting any changes
// or updates as we send full updates after replacing the policy.
// TODO(kradalby): This is kind of messy, maybe this is another +1
// for an event bus. See example comments here.
// autoApproveNodes automatically approves nodes based on policy rules.
func (s *State) autoApproveNodes() ([]change.Change, error) {
	nodes := s.ListNodes()

	// Approve routes concurrently, this should make it likely
	// that the writes end in the same batch in the nodestore write.
	var (
		errg errgroup.Group
		cs   []change.Change
		mu   sync.Mutex
	)
	for _, nv := range nodes.All() {
		errg.Go(func() error {
			approved, changed := policy.ApproveRoutesWithPolicy(s.polMan, nv, nv.ApprovedRoutes().AsSlice(), nv.AnnouncedRoutes())
			if changed {
				log.Debug().
					Uint64(zf.NodeID, nv.ID().Uint64()).
					Str(zf.NodeName, nv.Hostname()).
					Strs(zf.RoutesApprovedOld, util.PrefixesToString(nv.ApprovedRoutes().AsSlice())).
					Strs(zf.RoutesApprovedNew, util.PrefixesToString(approved)).
					Msg("Routes auto-approved by policy")

				_, c, err := s.SetApprovedRoutes(nv.ID(), approved)
				if err != nil {
					return err
				}

				mu.Lock()

				cs = append(cs, c)

				mu.Unlock()
			}

			return nil
		})
	}

	err := errg.Wait()
	if err != nil {
		return nil, err
	}

	return cs, nil
}

// isAutoDerivedGivenName reports whether given matches what
// dnsname.SanitizeHostname(hostname) would produce, optionally with a
// [NodeStore] collision-bump "-N" suffix. It is used to detect whether a
// GivenName has been admin-renamed (in which case it must not be
// overwritten by client-side hostname changes).
func isAutoDerivedGivenName(given, hostname string) bool {
	base := dnsname.SanitizeHostname(hostname)
	if given == base {
		return true
	}

	suffix, ok := strings.CutPrefix(given, base+"-")
	if !ok {
		return false
	}

	_, err := strconv.Atoi(suffix)

	return err == nil
}

// UpdateNodeFromMapRequest is the sync point where Hostinfo changes,
// endpoint updates, and route advertisements from a [tailcfg.MapRequest]
// land in the [NodeStore]. It produces a [change.Change] summarising
// what actually moved so downstream subsystems (mapper, policy, primary
// routes) can react accordingly.
//
// TODO(kradalby): This is essentially a patch update that could be sent directly to nodes,
// which means we could shortcut the whole change thing if there are no other important updates.
// When a field is added to this function, remember to also add it to:
// - node.PeerChangeFromMapRequest
// - node.ApplyPeerChange
// - logTracePeerChange in poll.go.
func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest) (change.Change, error) {
	log.Trace().
		Caller().
		Uint64(zf.NodeID, id.Uint64()).
		Interface("request", req).
		Msg("Processing MapRequest for node")

	var (
		routeChange        bool
		hostinfoChanged    bool
		needsRouteApproval bool
		autoApprovedRoutes []netip.Prefix
		endpointChanged    bool
		derpChanged        bool
	)
	// Snapshot the primary assignment so we can tell whether the
	// Hostinfo + auto-approval that follows shifted any prefix.
	prevRoutes := s.nodeStore.PrimaryRoutes()

	// We need to ensure we update the node as it is in the [NodeStore] at
	// the time of the request.
	updatedNode, ok := s.nodeStore.UpdateNode(id, func(currentNode *types.Node) {
		peerChange := currentNode.PeerChangeFromMapRequest(req)

		// Track what specifically changed
		endpointChanged = peerChange.Endpoints != nil
		derpChanged = peerChange.DERPRegion != 0
		hostinfoChanged = !hostinfoEqual(currentNode.View(), req.Hostinfo)

		// Get the correct NetInfo to use
		netInfo := netInfoFromMapRequest(id, currentNode.Hostinfo, req.Hostinfo)
		if req.Hostinfo != nil {
			req.Hostinfo.NetInfo = netInfo
		} else {
			req.Hostinfo = &tailcfg.Hostinfo{NetInfo: netInfo}
		}

		// Re-check hostinfoChanged after potential NetInfo preservation
		hostinfoChanged = !hostinfoEqual(currentNode.View(), req.Hostinfo)

		// If there is no changes and nothing to save,
		// return early.
		if peerChangeEmpty(peerChange) && !hostinfoChanged {
			return
		}

		// Calculate route approval before [NodeStore] update to avoid calling View() inside callback
		var hasNewRoutes bool
		if hi := req.Hostinfo; hi != nil {
			hasNewRoutes = len(hi.RoutableIPs) > 0
		}

		needsRouteApproval = hostinfoChanged && (routesChanged(currentNode.View(), req.Hostinfo) || (hasNewRoutes && len(currentNode.ApprovedRoutes) == 0))
		if needsRouteApproval {
			// Extract announced routes from request
			var announcedRoutes []netip.Prefix
			if req.Hostinfo != nil {
				announcedRoutes = req.Hostinfo.RoutableIPs
			}

			// Apply policy-based auto-approval if routes are announced
			if len(announcedRoutes) > 0 {
				autoApprovedRoutes, routeChange = policy.ApproveRoutesWithPolicy(
					s.polMan,
					currentNode.View(),
					currentNode.ApprovedRoutes,
					announcedRoutes,
				)
			}
		}

		// Log when routes change but approval doesn't
		if hostinfoChanged && !routeChange {
			if hi := req.Hostinfo; hi != nil {
				if routesChanged(currentNode.View(), hi) {
					log.Debug().
						Caller().
						Uint64(zf.NodeID, id.Uint64()).
						Strs(zf.OldAnnouncedRoutes, util.PrefixesToString(currentNode.AnnouncedRoutes())).
						Strs(zf.NewAnnouncedRoutes, util.PrefixesToString(hi.RoutableIPs)).
						Strs(zf.ApprovedRoutes, util.PrefixesToString(currentNode.ApprovedRoutes)).
						Bool(zf.RouteChanged, routeChange).
						Msg("announced routes changed but approved routes did not")
				}
			}
		}

		currentNode.ApplyPeerChange(&peerChange)

		if hostinfoChanged {
			// The node might not set NetInfo if it has not changed and if
			// the full HostInfo object is overwritten, the information is lost.
			// If there is no NetInfo, keep the previous one.
			// From 1.66 the client only sends it if changed:
			// https://github.com/tailscale/tailscale/commit/e1011f138737286ecf5123ff887a7a5800d129a2
			// TODO(kradalby): evaluate if we need better comparing of hostinfo
			// before we take the changes.
			// NetInfo preservation has already been handled above before early return check
			currentNode.Hostinfo = req.Hostinfo
			if req.Hostinfo != nil && req.Hostinfo.Hostname != "" {
				// Preserve an admin-renamed GivenName: only auto-derive when the
				// current GivenName is still what SanitizeHostname of the old
				// Hostname would produce (possibly with a "-N" collision bump).
				autoDerived := isAutoDerivedGivenName(currentNode.GivenName, currentNode.Hostname)

				currentNode.Hostname = req.Hostinfo.Hostname
				if autoDerived {
					currentNode.GivenName = dnsname.SanitizeHostname(req.Hostinfo.Hostname)
					// [NodeStore.UpdateNode] auto-bumps GivenName on collision.
				}
			}

			if routeChange {
				// Apply pre-calculated route approval
				// Always apply the route approval result to ensure consistency,
				// regardless of whether the policy evaluation detected changes.
				// This fixes the bug where routes weren't properly cleared when
				// auto-approvers were removed from the policy.
				log.Info().
					Uint64(zf.NodeID, id.Uint64()).
					Strs(zf.OldApprovedRoutes, util.PrefixesToString(currentNode.ApprovedRoutes)).
					Strs(zf.NewApprovedRoutes, util.PrefixesToString(autoApprovedRoutes)).
					Bool(zf.RouteChanged, routeChange).
					Msg("applying route approval results")
			}
		}

		// AllApprovedRoutes is announced ∩ approved; a Hostinfo
		// update that shrinks the announced set can drop the node
		// out of HA candidacy without touching ApprovedRoutes.
		// Clear any stale Unhealthy bit in that case.
		if len(currentNode.AllApprovedRoutes()) == 0 {
			currentNode.Unhealthy = false
		}
	})

	if !ok {
		return change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, id)
	}

	if routeChange {
		log.Debug().
			Uint64(zf.NodeID, id.Uint64()).
			Strs(zf.AutoApprovedRoutes, util.PrefixesToString(autoApprovedRoutes)).
			Msg("Persisting auto-approved routes from MapRequest")

		// [State.SetApprovedRoutes] will update both database and PrimaryRoutes table
		_, c, err := s.SetApprovedRoutes(id, autoApprovedRoutes)
		if err != nil {
			return change.Change{}, fmt.Errorf("persisting auto-approved routes: %w", err)
		}

		// If [State.SetApprovedRoutes] resulted in a policy change, return it
		if !c.IsEmpty() {
			return c, nil
		}
	} // Continue with the rest of the processing using the updated node

	// SubnetRoutes = announced ∩ approved, so a Hostinfo update can
	// move a primary without ever touching ApprovedRoutes. The pre/post
	// snapshot diff catches that.
	nodeRouteChange := change.Change{}

	if !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes()) {
		log.Debug().
			Caller().
			Uint64(zf.NodeID, id.Uint64()).
			Strs(zf.RoutesAnnounced, util.PrefixesToString(updatedNode.AnnouncedRoutes())).
			Strs(zf.ApprovedRoutes, util.PrefixesToString(updatedNode.ApprovedRoutes().AsSlice())).
			Strs(zf.AllApprovedRoutes, util.PrefixesToString(updatedNode.AllApprovedRoutes())).
			Msg("primary route assignment shifted after MapRequest")

		nodeRouteChange = change.PolicyChange()
	}

	_, policyChange, err := s.persistNodeToDB(updatedNode)
	if err != nil {
		return change.Change{}, fmt.Errorf("saving to database: %w", err)
	}

	if !policyChange.IsEmpty() {
		return policyChange, nil
	}

	if !nodeRouteChange.IsEmpty() {
		return nodeRouteChange, nil
	}

	// Determine the most specific change type based on what actually changed.
	// This allows us to send lightweight patch updates instead of full map responses.
	return buildMapRequestChangeResponse(id, updatedNode, hostinfoChanged, endpointChanged, derpChanged)
}

// buildMapRequestChangeResponse determines the appropriate response type for a [tailcfg.MapRequest] update.
// Hostinfo changes require a full update, while endpoint/DERP changes can use lightweight patches.
func buildMapRequestChangeResponse(
	id types.NodeID,
	node types.NodeView,
	hostinfoChanged, endpointChanged, derpChanged bool,
) (change.Change, error) {
	// Hostinfo changes require NodeAdded (full update) as they may affect many fields.
	if hostinfoChanged {
		return change.NodeAdded(id), nil
	}

	// Return specific change types for endpoint and/or DERP updates.
	if endpointChanged || derpChanged {
		patch := &tailcfg.PeerChange{NodeID: id.NodeID()}

		if endpointChanged {
			patch.Endpoints = node.Endpoints().AsSlice()
		}

		if derpChanged {
			if hi := node.Hostinfo(); hi.Valid() {
				if ni := hi.NetInfo(); ni.Valid() {
					patch.DERPRegion = ni.PreferredDERP()
				}
			}
		}

		return change.EndpointOrDERPUpdate(id, patch), nil
	}

	return change.NodeAdded(id), nil
}

func hostinfoEqual(oldNode types.NodeView, newHI *tailcfg.Hostinfo) bool {
	if !oldNode.Valid() && newHI == nil {
		return true
	}

	if !oldNode.Valid() || newHI == nil {
		return false
	}

	old := oldNode.AsStruct().Hostinfo

	return old.Equal(newHI)
}

func routesChanged(oldNode types.NodeView, newHI *tailcfg.Hostinfo) bool {
	var oldRoutes []netip.Prefix
	if oldNode.Valid() && oldNode.AsStruct().Hostinfo != nil {
		oldRoutes = oldNode.AsStruct().Hostinfo.RoutableIPs
	}

	newRoutes := slices.Clone(newHI.RoutableIPs)
	if newRoutes == nil {
		newRoutes = []netip.Prefix{}
	}

	slices.SortFunc(oldRoutes, netip.Prefix.Compare)
	slices.SortFunc(newRoutes, netip.Prefix.Compare)

	return !slices.Equal(oldRoutes, newRoutes)
}

func peerChangeEmpty(peerChange tailcfg.PeerChange) bool {
	return peerChange.Key == nil &&
		peerChange.DiscoKey == nil &&
		peerChange.Online == nil &&
		peerChange.Endpoints == nil &&
		peerChange.DERPRegion == 0 &&
		peerChange.LastSeen == nil &&
		peerChange.KeyExpiry == nil
}
