// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package controlclient

import (
	"context"
	"encoding/json"
	"fmt"
	"net/netip"
	"reflect"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"go4.org/mem"
	"tailscale.com/control/controlknobs"
	"tailscale.com/tailcfg"
	"tailscale.com/tstest"
	"tailscale.com/tstime"
	"tailscale.com/types/dnstype"
	"tailscale.com/types/key"
	"tailscale.com/types/logger"
	"tailscale.com/types/netmap"
	"tailscale.com/types/ptr"
	"tailscale.com/util/mak"
	"tailscale.com/util/must"
)

func eps(s ...string) []netip.AddrPort {
	var eps []netip.AddrPort
	for _, ep := range s {
		eps = append(eps, netip.MustParseAddrPort(ep))
	}
	return eps
}

func TestUpdatePeersStateFromResponse(t *testing.T) {
	var curTime time.Time

	online := func(v bool) func(*tailcfg.Node) {
		return func(n *tailcfg.Node) {
			n.Online = &v
		}
	}
	seenAt := func(t time.Time) func(*tailcfg.Node) {
		return func(n *tailcfg.Node) {
			n.LastSeen = &t
		}
	}
	withDERP := func(d string) func(*tailcfg.Node) {
		return func(n *tailcfg.Node) {
			n.DERP = d
		}
	}
	withEP := func(ep string) func(*tailcfg.Node) {
		return func(n *tailcfg.Node) {
			n.Endpoints = []netip.AddrPort{netip.MustParseAddrPort(ep)}
		}
	}
	n := func(id tailcfg.NodeID, name string, mod ...func(*tailcfg.Node)) *tailcfg.Node {
		n := &tailcfg.Node{ID: id, Name: name}
		for _, f := range mod {
			f(n)
		}
		return n
	}
	peers := func(nv ...*tailcfg.Node) []*tailcfg.Node { return nv }
	tests := []struct {
		name      string
		mapRes    *tailcfg.MapResponse
		curTime   time.Time
		prev      []*tailcfg.Node
		want      []*tailcfg.Node
		wantStats updateStats
	}{
		{
			name: "full_peers",
			mapRes: &tailcfg.MapResponse{
				Peers: peers(n(1, "foo"), n(2, "bar")),
			},
			want: peers(n(1, "foo"), n(2, "bar")),
			wantStats: updateStats{
				allNew: true,
				added:  2,
			},
		},
		{
			name: "full_peers_ignores_deltas",
			mapRes: &tailcfg.MapResponse{
				Peers:        peers(n(1, "foo"), n(2, "bar")),
				PeersRemoved: []tailcfg.NodeID{2},
			},
			want: peers(n(1, "foo"), n(2, "bar")),
			wantStats: updateStats{
				allNew: true,
				added:  2,
			},
		},
		{
			name: "add_and_update",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				PeersChanged: peers(n(0, "zero"), n(2, "bar2"), n(3, "three")),
			},
			want: peers(n(0, "zero"), n(1, "foo"), n(2, "bar2"), n(3, "three")),
			wantStats: updateStats{
				added:   2, // added IDs 0 and 3
				changed: 1, // changed ID 2
			},
		},
		{
			name: "remove",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				PeersRemoved: []tailcfg.NodeID{1, 3, 4},
			},
			want: peers(n(2, "bar")),
			wantStats: updateStats{
				removed: 1, // ID 1
			},
		},
		{
			name: "add_and_remove",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				PeersChanged: peers(n(1, "foo2")),
				PeersRemoved: []tailcfg.NodeID{2},
			},
			want: peers(n(1, "foo2")),
			wantStats: updateStats{
				changed: 1,
				removed: 1,
			},
		},
		{
			name:   "unchanged",
			prev:   peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{},
			want:   peers(n(1, "foo"), n(2, "bar")),
		},
		{
			name: "online_change",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				OnlineChange: map[tailcfg.NodeID]bool{
					1:   true,
					404: true,
				},
			},
			want: peers(
				n(1, "foo", online(true)),
				n(2, "bar"),
			),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "online_change_offline",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				OnlineChange: map[tailcfg.NodeID]bool{
					1: false,
					2: true,
				},
			},
			want: peers(
				n(1, "foo", online(false)),
				n(2, "bar", online(true)),
			),
			wantStats: updateStats{changed: 2},
		},
		{
			name:    "peer_seen_at",
			prev:    peers(n(1, "foo", seenAt(time.Unix(111, 0))), n(2, "bar")),
			curTime: time.Unix(123, 0),
			mapRes: &tailcfg.MapResponse{
				PeerSeenChange: map[tailcfg.NodeID]bool{
					1: false,
					2: true,
				},
			},
			want: peers(
				n(1, "foo"),
				n(2, "bar", seenAt(time.Unix(123, 0))),
			),
			wantStats: updateStats{changed: 2},
		},
		{
			name: "ep_change_derp",
			prev: peers(n(1, "foo", withDERP("127.3.3.40:3"))),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:     1,
					DERPRegion: 4,
				}},
			},
			want:      peers(n(1, "foo", withDERP("127.3.3.40:4"))),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "ep_change_udp",
			prev: peers(n(1, "foo", withEP("1.2.3.4:111"))),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:    1,
					Endpoints: eps("1.2.3.4:56"),
				}},
			},
			want:      peers(n(1, "foo", withEP("1.2.3.4:56"))),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "ep_change_udp_2",
			prev: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:111"))),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:    1,
					Endpoints: eps("1.2.3.4:56"),
				}},
			},
			want:      peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:56"))),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "ep_change_both",
			prev: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:111"))),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:     1,
					DERPRegion: 2,
					Endpoints:  eps("1.2.3.4:56"),
				}},
			},
			want:      peers(n(1, "foo", withDERP("127.3.3.40:2"), withEP("1.2.3.4:56"))),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_key",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID: 1,
					Key:    ptr.To(key.NodePublicFromRaw32(mem.B(append(make([]byte, 31), 'A')))),
				}},
			}, want: peers(&tailcfg.Node{
				ID:   1,
				Name: "foo",
				Key:  key.NodePublicFromRaw32(mem.B(append(make([]byte, 31), 'A'))),
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_key_signature",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:       1,
					KeySignature: []byte{3, 4},
				}},
			},
			want: peers(&tailcfg.Node{
				ID:           1,
				Name:         "foo",
				KeySignature: []byte{3, 4},
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_disco_key",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:   1,
					DiscoKey: ptr.To(key.DiscoPublicFromRaw32(mem.B(append(make([]byte, 31), 'A')))),
				}},
			},
			want: peers(&tailcfg.Node{
				ID:       1,
				Name:     "foo",
				DiscoKey: key.DiscoPublicFromRaw32(mem.B(append(make([]byte, 31), 'A'))),
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_online",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID: 1,
					Online: ptr.To(true),
				}},
			},
			want: peers(&tailcfg.Node{
				ID:     1,
				Name:   "foo",
				Online: ptr.To(true),
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_last_seen",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:   1,
					LastSeen: ptr.To(time.Unix(123, 0).UTC()),
				}},
			},
			want: peers(&tailcfg.Node{
				ID:       1,
				Name:     "foo",
				LastSeen: ptr.To(time.Unix(123, 0).UTC()),
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_key_expiry",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:    1,
					KeyExpiry: ptr.To(time.Unix(123, 0).UTC()),
				}},
			},
			want: peers(&tailcfg.Node{
				ID:        1,
				Name:      "foo",
				KeyExpiry: time.Unix(123, 0).UTC(),
			}),
			wantStats: updateStats{changed: 1},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if !tt.curTime.IsZero() {
				curTime = tt.curTime
				tstest.Replace(t, &clock, tstime.Clock(tstest.NewClock(tstest.ClockOpts{Start: curTime})))
			}
			ms := newTestMapSession(t, nil)
			for _, n := range tt.prev {
				mak.Set(&ms.peers, n.ID, ptr.To(n.View()))
			}
			ms.rebuildSorted()

			gotStats := ms.updatePeersStateFromResponse(tt.mapRes)

			got := make([]*tailcfg.Node, len(ms.sortedPeers))
			for i, vp := range ms.sortedPeers {
				got[i] = vp.AsStruct()
			}
			if gotStats != tt.wantStats {
				t.Errorf("got stats = %+v; want %+v", gotStats, tt.wantStats)
			}
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("wrong results\n got: %s\nwant: %s", formatNodes(got), formatNodes(tt.want))
			}
		})
	}
}

func formatNodes(nodes []*tailcfg.Node) string {
	var sb strings.Builder
	for i, n := range nodes {
		if i > 0 {
			sb.WriteString(", ")
		}
		fmt.Fprintf(&sb, "(%d, %q", n.ID, n.Name)

		if n.Online != nil {
			fmt.Fprintf(&sb, ", online=%v", *n.Online)
		}
		if n.LastSeen != nil {
			fmt.Fprintf(&sb, ", lastSeen=%v", n.LastSeen.Unix())
		}
		if n.Key != (key.NodePublic{}) {
			fmt.Fprintf(&sb, ", key=%v", n.Key.String())
		}
		if n.Expired {
			fmt.Fprintf(&sb, ", expired=true")
		}
		sb.WriteString(")")
	}
	return sb.String()
}

func newTestMapSession(t testing.TB, nu NetmapUpdater) *mapSession {
	ms := newMapSession(key.NewNode(), nu, new(controlknobs.Knobs))
	t.Cleanup(ms.Close)
	ms.logf = t.Logf
	return ms
}

func (ms *mapSession) netmapForResponse(res *tailcfg.MapResponse) *netmap.NetworkMap {
	ms.updateStateFromResponse(res)
	return ms.netmap()
}

func TestNetmapForResponse(t *testing.T) {
	t.Run("implicit_packetfilter", func(t *testing.T) {
		somePacketFilter := []tailcfg.FilterRule{
			{
				SrcIPs: []string{"*"},
				DstPorts: []tailcfg.NetPortRange{
					{IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}},
				},
			},
		}
		ms := newTestMapSession(t, nil)
		nm1 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: somePacketFilter,
		})
		if len(nm1.PacketFilter) == 0 {
			t.Fatalf("zero length PacketFilter")
		}
		nm2 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: nil, // testing that the server can omit this.
		})
		if len(nm1.PacketFilter) == 0 {
			t.Fatalf("zero length PacketFilter in 2nd netmap")
		}
		if !reflect.DeepEqual(nm1.PacketFilter, nm2.PacketFilter) {
			t.Error("packet filters differ")
		}
	})
	t.Run("implicit_dnsconfig", func(t *testing.T) {
		someDNSConfig := &tailcfg.DNSConfig{Domains: []string{"foo", "bar"}}
		ms := newTestMapSession(t, nil)
		nm1 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:      new(tailcfg.Node),
			DNSConfig: someDNSConfig,
		})
		if !reflect.DeepEqual(nm1.DNS, *someDNSConfig) {
			t.Fatalf("1st DNS wrong")
		}
		nm2 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:      new(tailcfg.Node),
			DNSConfig: nil, // implicit
		})
		if !reflect.DeepEqual(nm2.DNS, *someDNSConfig) {
			t.Fatalf("2nd DNS wrong")
		}
	})
	t.Run("collect_services", func(t *testing.T) {
		ms := newTestMapSession(t, nil)
		var nm *netmap.NetworkMap
		wantCollect := func(v bool) {
			t.Helper()
			if nm.CollectServices != v {
				t.Errorf("netmap.CollectServices = %v; want %v", nm.CollectServices, v)
			}
		}

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node: new(tailcfg.Node),
		})
		wantCollect(false)

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node:            new(tailcfg.Node),
			CollectServices: "false",
		})
		wantCollect(false)

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node:            new(tailcfg.Node),
			CollectServices: "true",
		})
		wantCollect(true)

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node:            new(tailcfg.Node),
			CollectServices: "",
		})
		wantCollect(true)
	})
	t.Run("implicit_domain", func(t *testing.T) {
		ms := newTestMapSession(t, nil)
		var nm *netmap.NetworkMap
		want := func(v string) {
			t.Helper()
			if nm.Domain != v {
				t.Errorf("netmap.Domain = %q; want %q", nm.Domain, v)
			}
		}
		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node:   new(tailcfg.Node),
			Domain: "foo.com",
		})
		want("foo.com")

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node: new(tailcfg.Node),
		})
		want("foo.com")
	})
	t.Run("implicit_node", func(t *testing.T) {
		someNode := &tailcfg.Node{
			Name: "foo",
		}
		wantNode := (&tailcfg.Node{
			Name:                 "foo",
			ComputedName:         "foo",
			ComputedNameWithHost: "foo",
		}).View()
		ms := newTestMapSession(t, nil)
		mapRes := &tailcfg.MapResponse{
			Node: someNode,
		}
		initDisplayNames(mapRes.Node.View(), mapRes)
		ms.updateStateFromResponse(mapRes)
		nm1 := ms.netmap()
		if !nm1.SelfNode.Valid() {
			t.Fatal("nil Node in 1st netmap")
		}
		if !reflect.DeepEqual(nm1.SelfNode, wantNode) {
			j, _ := json.Marshal(nm1.SelfNode)
			t.Errorf("Node mismatch in 1st netmap; got: %s", j)
		}

		ms.updateStateFromResponse(&tailcfg.MapResponse{})
		nm2 := ms.netmap()
		if !nm2.SelfNode.Valid() {
			t.Fatal("nil Node in 1st netmap")
		}
		if !reflect.DeepEqual(nm2.SelfNode, wantNode) {
			j, _ := json.Marshal(nm2.SelfNode)
			t.Errorf("Node mismatch in 2nd netmap; got: %s", j)
		}
	})
	t.Run("named_packetfilter", func(t *testing.T) {
		pfA := []tailcfg.FilterRule{
			{
				SrcIPs: []string{"10.0.0.1"},
				DstPorts: []tailcfg.NetPortRange{
					{IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}},
				},
			},
		}
		pfB := []tailcfg.FilterRule{
			{
				SrcIPs: []string{"10.0.0.2"},
				DstPorts: []tailcfg.NetPortRange{
					{IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}},
				},
			},
		}
		ms := newTestMapSession(t, nil)

		// Mix of old & new style (PacketFilter and PacketFilters).
		nm1 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: pfA,
			PacketFilters: map[string][]tailcfg.FilterRule{
				"pf-b": pfB,
			},
		})
		if got, want := len(nm1.PacketFilter), 2; got != want {
			t.Fatalf("PacketFilter length = %v; want %v", got, want)
		}
		if got, want := first(nm1.PacketFilter[0].Srcs).String(), "10.0.0.1/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}
		if got, want := first(nm1.PacketFilter[1].Srcs).String(), "10.0.0.2/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}

		// No-op change. Remember the old stuff.
		nm2 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:          new(tailcfg.Node),
			PacketFilter:  nil,
			PacketFilters: nil,
		})
		if got, want := len(nm2.PacketFilter), 2; got != want {
			t.Fatalf("PacketFilter length = %v; want %v", got, want)
		}
		if !reflect.DeepEqual(nm1.PacketFilter, nm2.PacketFilter) {
			t.Error("packet filters differ")
		}

		// New style only, with clear.
		nm3 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: nil,
			PacketFilters: map[string][]tailcfg.FilterRule{
				"*":    nil,
				"pf-b": pfB,
			},
		})
		if got, want := len(nm3.PacketFilter), 1; got != want {
			t.Fatalf("PacketFilter length = %v; want %v", got, want)
		}
		if got, want := first(nm3.PacketFilter[0].Srcs).String(), "10.0.0.2/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}

		// New style only, adding pfA back, not as the legacy "base" layer:.
		nm4 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: nil,
			PacketFilters: map[string][]tailcfg.FilterRule{
				"pf-a": pfA,
			},
		})
		if got, want := len(nm4.PacketFilter), 2; got != want {
			t.Fatalf("PacketFilter length = %v; want %v", got, want)
		}
		if got, want := first(nm4.PacketFilter[0].Srcs).String(), "10.0.0.1/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}
		if got, want := first(nm4.PacketFilter[1].Srcs).String(), "10.0.0.2/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}
	})
}

func first[T any](s []T) T {
	if len(s) == 0 {
		var zero T
		return zero
	}
	return s[0]
}

func TestDeltaDERPMap(t *testing.T) {
	regions1 := map[int]*tailcfg.DERPRegion{
		1: {
			RegionID: 1,
			Nodes: []*tailcfg.DERPNode{{
				Name:     "derp1a",
				RegionID: 1,
				HostName: "derp1a" + tailcfg.DotInvalid,
				IPv4:     "169.254.169.254",
				IPv6:     "none",
			}},
		},
	}

	// As above, but with a changed IPv4 addr
	regions2 := map[int]*tailcfg.DERPRegion{1: regions1[1].Clone()}
	regions2[1].Nodes[0].IPv4 = "127.0.0.1"

	type step struct {
		got  *tailcfg.DERPMap
		want *tailcfg.DERPMap
	}
	tests := []struct {
		name  string
		steps []step
	}{
		{
			name: "nothing-to-nothing",
			steps: []step{
				{nil, nil},
				{nil, nil},
			},
		},
		{
			name: "regions-sticky",
			steps: []step{
				{&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}},
				{&tailcfg.DERPMap{}, &tailcfg.DERPMap{Regions: regions1}},
			},
		},
		{
			name: "regions-change",
			steps: []step{
				{&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}},
				{&tailcfg.DERPMap{Regions: regions2}, &tailcfg.DERPMap{Regions: regions2}},
			},
		},
		{
			name: "home-params",
			steps: []step{
				// Send a DERP map
				{&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}},
				// Send home params, want to still have the same regions
				{
					&tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
				},
			},
		},
		{
			name: "home-params-sub-fields",
			steps: []step{
				// Send a DERP map with home params
				{
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
				},
				// Sending a struct with a 'HomeParams' field but nil RegionScore doesn't change home params...
				{
					&tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{RegionScore: nil}},
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
				},
				// ... but sending one with a non-nil and empty RegionScore field zeroes that out.
				{
					&tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{RegionScore: map[int]float64{}}},
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{},
					}},
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ms := newTestMapSession(t, nil)
			for stepi, s := range tt.steps {
				nm := ms.netmapForResponse(&tailcfg.MapResponse{DERPMap: s.got})
				if !reflect.DeepEqual(nm.DERPMap, s.want) {
					t.Errorf("unexpected result at step index %v; got: %s", stepi, logger.AsJSON(nm.DERPMap))
				}
			}
		})
	}
}

func TestPeerChangeDiff(t *testing.T) {
	tests := []struct {
		name      string
		a, b      *tailcfg.Node
		want      *tailcfg.PeerChange // nil means want ok=false, unless wantEqual is set
		wantEqual bool                // means test wants (nil, true)
	}{
		{
			name:      "eq",
			a:         &tailcfg.Node{ID: 1},
			b:         &tailcfg.Node{ID: 1},
			wantEqual: true,
		},
		{
			name: "patch-derp",
			a:    &tailcfg.Node{ID: 1, DERP: "127.3.3.40:1"},
			b:    &tailcfg.Node{ID: 1, DERP: "127.3.3.40:2"},
			want: &tailcfg.PeerChange{NodeID: 1, DERPRegion: 2},
		},
		{
			name: "patch-endpoints",
			a:    &tailcfg.Node{ID: 1, Endpoints: eps("10.0.0.1:1")},
			b:    &tailcfg.Node{ID: 1, Endpoints: eps("10.0.0.2:2")},
			want: &tailcfg.PeerChange{NodeID: 1, Endpoints: eps("10.0.0.2:2")},
		},
		{
			name: "patch-cap",
			a:    &tailcfg.Node{ID: 1, Cap: 1},
			b:    &tailcfg.Node{ID: 1, Cap: 2},
			want: &tailcfg.PeerChange{NodeID: 1, Cap: 2},
		},
		{
			name: "patch-lastseen",
			a:    &tailcfg.Node{ID: 1, LastSeen: ptr.To(time.Unix(1, 0))},
			b:    &tailcfg.Node{ID: 1, LastSeen: ptr.To(time.Unix(2, 0))},
			want: &tailcfg.PeerChange{NodeID: 1, LastSeen: ptr.To(time.Unix(2, 0))},
		},
		{
			name: "patch-online-to-true",
			a:    &tailcfg.Node{ID: 1, Online: ptr.To(false)},
			b:    &tailcfg.Node{ID: 1, Online: ptr.To(true)},
			want: &tailcfg.PeerChange{NodeID: 1, Online: ptr.To(true)},
		},
		{
			name: "patch-online-to-false",
			a:    &tailcfg.Node{ID: 1, Online: ptr.To(true)},
			b:    &tailcfg.Node{ID: 1, Online: ptr.To(false)},
			want: &tailcfg.PeerChange{NodeID: 1, Online: ptr.To(false)},
		},
		{
			name: "mix-patchable-and-not",
			a:    &tailcfg.Node{ID: 1, Cap: 1},
			b:    &tailcfg.Node{ID: 1, Cap: 2, StableID: "foo"},
			want: nil,
		},
		{
			name: "miss-change-stableid",
			a:    &tailcfg.Node{ID: 1},
			b:    &tailcfg.Node{ID: 1, StableID: "diff"},
			want: nil,
		},
		{
			name: "miss-change-id",
			a:    &tailcfg.Node{ID: 1},
			b:    &tailcfg.Node{ID: 2},
			want: nil,
		},
		{
			name: "miss-change-name",
			a:    &tailcfg.Node{ID: 1, Name: "foo"},
			b:    &tailcfg.Node{ID: 1, Name: "bar"},
			want: nil,
		},
		{
			name: "miss-change-user",
			a:    &tailcfg.Node{ID: 1, User: 1},
			b:    &tailcfg.Node{ID: 1, User: 2},
			want: nil,
		},
		{
			name: "miss-change-masq-v4",
			a:    &tailcfg.Node{ID: 1, SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))},
			b:    &tailcfg.Node{ID: 1, SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.2"))},
			want: nil,
		},
		{
			name: "miss-change-masq-v6",
			a:    &tailcfg.Node{ID: 1, SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3456"))},
			b:    &tailcfg.Node{ID: 1, SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3006"))},
			want: nil,
		},
		{
			name: "patch-capmap-add-value-to-existing-key",
			a:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: []tailcfg.RawMessage{"true"}}},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: []tailcfg.RawMessage{"true"}}},
		},
		{
			name: "patch-capmap-add-new-key",
			a:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil, tailcfg.CapabilityDebug: nil}},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil, tailcfg.CapabilityDebug: nil}},
		}, {
			name: "patch-capmap-remove-key",
			a:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{}},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{}},
		}, {
			name: "patch-capmap-remove-as-nil",
			a:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:    &tailcfg.Node{ID: 1},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{}},
		}, {
			name: "patch-capmap-add-key-to-empty-map",
			a:    &tailcfg.Node{ID: 1},
			b:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
		},
		{
			name:      "patch-capmap-no-change",
			a:         &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:         &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			wantEqual: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			pc, ok := peerChangeDiff(tt.a.View(), tt.b)
			if tt.wantEqual {
				if !ok || pc != nil {
					t.Errorf("got (%p, %v); want (nil, true); pc=%v", pc, ok, logger.AsJSON(pc))
				}
				return
			}
			if (pc != nil) != ok {
				t.Fatalf("inconsistent ok=%v, pc=%p", ok, pc)
			}
			if !reflect.DeepEqual(pc, tt.want) {
				t.Errorf("mismatch\n got: %v\nwant: %v\n", logger.AsJSON(pc), logger.AsJSON(tt.want))
			}
		})
	}
}

func TestPeerChangeDiffAllocs(t *testing.T) {
	a := &tailcfg.Node{ID: 1}
	b := &tailcfg.Node{ID: 1}
	n := testing.AllocsPerRun(10000, func() {
		diff, ok := peerChangeDiff(a.View(), b)
		if !ok || diff != nil {
			t.Fatalf("unexpected result: (%s, %v)", logger.AsJSON(diff), ok)
		}
	})
	if n != 0 {
		t.Errorf("allocs = %v; want 0", int(n))
	}
}

type countingNetmapUpdater struct {
	full atomic.Int64
}

func (nu *countingNetmapUpdater) UpdateFullNetmap(nm *netmap.NetworkMap) {
	nu.full.Add(1)
}

// tests (*mapSession).patchifyPeersChanged; smaller tests are in TestPeerChangeDiff
func TestPatchifyPeersChanged(t *testing.T) {
	hi := (&tailcfg.Hostinfo{}).View()
	tests := []struct {
		name string
		mr0  *tailcfg.MapResponse // initial
		mr1  *tailcfg.MapResponse // incremental
		want *tailcfg.MapResponse // what the incremental one should've been mutated to
	}{
		{
			name: "change_one_endpoint",
			mr0: &tailcfg.MapResponse{
				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
				Peers: []*tailcfg.Node{
					{ID: 1, Hostinfo: hi},
				},
			},
			mr1: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, Endpoints: eps("10.0.0.1:1111"), Hostinfo: hi},
				},
			},
			want: &tailcfg.MapResponse{
				PeersChanged: nil,
				PeersChangedPatch: []*tailcfg.PeerChange{
					{NodeID: 1, Endpoints: eps("10.0.0.1:1111")},
				},
			},
		},
		{
			name: "change_some",
			mr0: &tailcfg.MapResponse{
				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
				Peers: []*tailcfg.Node{
					{ID: 1, DERP: "127.3.3.40:1", Hostinfo: hi},
					{ID: 2, DERP: "127.3.3.40:2", Hostinfo: hi},
					{ID: 3, DERP: "127.3.3.40:3", Hostinfo: hi},
				},
			},
			mr1: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, DERP: "127.3.3.40:11", Hostinfo: hi},
					{ID: 2, StableID: "other-change", Hostinfo: hi},
					{ID: 3, DERP: "127.3.3.40:33", Hostinfo: hi},
					{ID: 4, DERP: "127.3.3.40:4", Hostinfo: hi},
				},
			},
			want: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 2, StableID: "other-change", Hostinfo: hi},
					{ID: 4, DERP: "127.3.3.40:4", Hostinfo: hi},
				},
				PeersChangedPatch: []*tailcfg.PeerChange{
					{NodeID: 1, DERPRegion: 11},
					{NodeID: 3, DERPRegion: 33},
				},
			},
		},
		{
			name: "change_exitnodednsresolvers",
			mr0: &tailcfg.MapResponse{
				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
				Peers: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi},
				},
			},
			mr1: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns2.exmaple.com"}}, Hostinfo: hi},
				},
			},
			want: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns2.exmaple.com"}}, Hostinfo: hi},
				},
			},
		},
		{
			name: "same_exitnoderesolvers",
			mr0: &tailcfg.MapResponse{
				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
				Peers: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi},
				},
			},
			mr1: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi},
				},
			},
			want: &tailcfg.MapResponse{},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			nu := &countingNetmapUpdater{}
			ms := newTestMapSession(t, nu)
			ms.updateStateFromResponse(tt.mr0)
			mr1 := new(tailcfg.MapResponse)
			must.Do(json.Unmarshal(must.Get(json.Marshal(tt.mr1)), mr1))
			ms.patchifyPeersChanged(mr1)
			opts := []cmp.Option{
				cmp.Comparer(func(a, b netip.AddrPort) bool { return a == b }),
			}
			if diff := cmp.Diff(tt.want, mr1, opts...); diff != "" {
				t.Errorf("wrong result (-want +got):\n%s", diff)
			}
		})
	}
}

func BenchmarkMapSessionDelta(b *testing.B) {
	for _, size := range []int{10, 100, 1_000, 10_000} {
		b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
			ctx := context.Background()
			nu := &countingNetmapUpdater{}
			ms := newTestMapSession(b, nu)
			res := &tailcfg.MapResponse{
				Node: &tailcfg.Node{
					ID:   1,
					Name: "foo.bar.ts.net.",
				},
			}
			for i := range size {
				res.Peers = append(res.Peers, &tailcfg.Node{
					ID:         tailcfg.NodeID(i + 2),
					Name:       fmt.Sprintf("peer%d.bar.ts.net.", i),
					DERP:       "127.3.3.40:10",
					Addresses:  []netip.Prefix{netip.MustParsePrefix("100.100.2.3/32"), netip.MustParsePrefix("fd7a:115c:a1e0::123/128")},
					AllowedIPs: []netip.Prefix{netip.MustParsePrefix("100.100.2.3/32"), netip.MustParsePrefix("fd7a:115c:a1e0::123/128")},
					Endpoints:  eps("192.168.1.2:345", "192.168.1.3:678"),
					Hostinfo: (&tailcfg.Hostinfo{
						OS:       "fooOS",
						Hostname: "MyHostname",
						Services: []tailcfg.Service{
							{Proto: "peerapi4", Port: 1234},
							{Proto: "peerapi6", Port: 1234},
							{Proto: "peerapi-dns-proxy", Port: 1},
						},
					}).View(),
					LastSeen: ptr.To(time.Unix(int64(i), 0)),
				})
			}
			ms.HandleNonKeepAliveMapResponse(ctx, res)

			b.ResetTimer()
			b.ReportAllocs()

			// Now for the core of the benchmark loop, just toggle
			// a single node's online status.
			for i := range b.N {
				if err := ms.HandleNonKeepAliveMapResponse(ctx, &tailcfg.MapResponse{
					OnlineChange: map[tailcfg.NodeID]bool{
						2: i%2 == 0,
					},
				}); err != nil {
					b.Fatal(err)
				}
			}
		})
	}
}
