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

package magicsock

import (
	"testing"

	"tailscale.com/disco"
	udprelay "tailscale.com/net/udprelay/endpoint"
	"tailscale.com/types/key"
	"tailscale.com/util/set"
)

func TestRelayManagerInitAndIdle(t *testing.T) {
	rm := relayManager{}
	rm.startUDPRelayPathDiscoveryFor(&endpoint{}, addrQuality{}, false)
	<-rm.runLoopStoppedCh

	rm = relayManager{}
	rm.stopWork(&endpoint{})
	<-rm.runLoopStoppedCh

	rm = relayManager{}
	c1 := &Conn{}
	c1.discoAtomic.Set(key.NewDisco())
	rm.handleCallMeMaybeVia(&endpoint{c: c1}, addrQuality{}, false, &disco.CallMeMaybeVia{UDPRelayEndpoint: disco.UDPRelayEndpoint{ServerDisco: key.NewDisco().Public()}})
	<-rm.runLoopStoppedCh

	rm = relayManager{}
	c2 := &Conn{}
	c2.discoAtomic.Set(key.NewDisco())
	rm.handleRxDiscoMsg(c2, &disco.BindUDPRelayEndpointChallenge{}, key.NodePublic{}, key.DiscoPublic{}, epAddr{})
	<-rm.runLoopStoppedCh

	rm = relayManager{}
	rm.handleRelayServersSet(make(set.Set[candidatePeerRelay]))
	<-rm.runLoopStoppedCh

	rm = relayManager{}
	rm.getServers()
	<-rm.runLoopStoppedCh

	rm = relayManager{}
	rm.handleDERPHomeChange(key.NodePublic{}, 1)
	<-rm.runLoopStoppedCh
}

func TestRelayManagerHandleDERPHomeChange(t *testing.T) {
	rm := relayManager{}
	servers := make(set.Set[candidatePeerRelay], 1)
	c := candidatePeerRelay{
		nodeKey:          key.NewNode().Public(),
		discoKey:         key.NewDisco().Public(),
		derpHomeRegionID: 1,
	}
	servers.Add(c)
	rm.handleRelayServersSet(servers)
	want := c
	want.derpHomeRegionID = 2
	rm.handleDERPHomeChange(c.nodeKey, 2)
	got := rm.getServers()
	if len(got) != 1 {
		t.Fatalf("got %d servers, want 1", len(got))
	}
	_, ok := got[want]
	if !ok {
		t.Fatal("DERP home change failed to propagate")
	}
}

func TestRelayManagerGetServers(t *testing.T) {
	rm := relayManager{}
	servers := make(set.Set[candidatePeerRelay], 1)
	c := candidatePeerRelay{
		nodeKey:  key.NewNode().Public(),
		discoKey: key.NewDisco().Public(),
	}
	servers.Add(c)
	rm.handleRelayServersSet(servers)
	got := rm.getServers()
	if !servers.Equal(got) {
		t.Errorf("got %v != want %v", got, servers)
	}
}

func TestRelayManager_handleNewServerEndpointRunLoop(t *testing.T) {
	wantHandshakeWorkCount := func(t *testing.T, rm *relayManager, n int) {
		t.Helper()
		byServerDiscoByEndpoint := 0
		for _, v := range rm.handshakeWorkByServerDiscoByEndpoint {
			byServerDiscoByEndpoint += len(v)
		}
		byServerDiscoVNI := len(rm.handshakeWorkByServerDiscoVNI)
		if byServerDiscoByEndpoint != n ||
			byServerDiscoVNI != n ||
			byServerDiscoByEndpoint != byServerDiscoVNI {
			t.Fatalf("want handshake work count %d byServerDiscoByEndpoint=%d byServerDiscoVNI=%d",
				n,
				byServerDiscoByEndpoint,
				byServerDiscoVNI,
			)
		}
	}

	conn := newConn(t.Logf)
	epA := &endpoint{c: conn}
	epB := &endpoint{c: conn}
	serverDiscoA := key.NewDisco().Public()
	serverDiscoB := key.NewDisco().Public()

	// getAddrQuality returns an addrQuality with serverDisco with the provided
	// VNI set. A negative vni leaves the VNI unset.
	getAddrQuality := func(serverDisco key.DiscoPublic, vni int) addrQuality {
		aq := addrQuality{relayServerDisco: serverDisco}
		if vni >= 0 {
			aq.epAddr.vni.Set(uint32(vni))
		}
		return aq
	}

	serverAendpointALamport1VNI1 := newRelayServerEndpointEvent{
		wlb: endpointWithLastBest{ep: epA},
		se:  udprelay.ServerEndpoint{ServerDisco: serverDiscoA, LamportID: 1, VNI: 1},
	}
	// lastBest matches the new server endpoint on both server disco and VNI, and
	// is trusted: suppression should fire.
	serverAendpointALamport1VNI1LastBestMatching := newRelayServerEndpointEvent{
		wlb: endpointWithLastBest{ep: epA, lastBestIsTrusted: true, lastBest: getAddrQuality(serverDiscoA, 1)},
		se:  udprelay.ServerEndpoint{ServerDisco: serverDiscoA, LamportID: 1, VNI: 1},
	}
	// lastBest matches the new server endpoint on server disco but NOT VNI (1 vs
	// 2), and is trusted: suppression should NOT fire.
	serverAendpointALamport1VNI1LastBestMatchingServerNeqVNI := newRelayServerEndpointEvent{
		wlb: endpointWithLastBest{ep: epA, lastBestIsTrusted: true, lastBest: getAddrQuality(serverDiscoA, 2)},
		se:  udprelay.ServerEndpoint{ServerDisco: serverDiscoA, LamportID: 1, VNI: 1},
	}
	// lastBest matches the new server endpoint on server disco, is trusted, but
	// has no VNI set: suppression should NOT fire.
	serverAendpointALamport1VNI1LastBestMatchingServerUnsetVNI := newRelayServerEndpointEvent{
		wlb: endpointWithLastBest{ep: epA, lastBestIsTrusted: true, lastBest: getAddrQuality(serverDiscoA, -1)},
		se:  udprelay.ServerEndpoint{ServerDisco: serverDiscoA, LamportID: 1, VNI: 1},
	}
	serverAendpointALamport2VNI1 := newRelayServerEndpointEvent{
		wlb: endpointWithLastBest{ep: epA},
		se:  udprelay.ServerEndpoint{ServerDisco: serverDiscoA, LamportID: 2, VNI: 1},
	}
	serverAendpointALamport2VNI2 := newRelayServerEndpointEvent{
		wlb: endpointWithLastBest{ep: epA},
		se:  udprelay.ServerEndpoint{ServerDisco: serverDiscoA, LamportID: 2, VNI: 2},
	}
	serverAendpointBLamport1VNI2 := newRelayServerEndpointEvent{
		wlb: endpointWithLastBest{ep: epB},
		se:  udprelay.ServerEndpoint{ServerDisco: serverDiscoA, LamportID: 1, VNI: 2},
	}
	serverBendpointALamport1VNI1 := newRelayServerEndpointEvent{
		wlb: endpointWithLastBest{ep: epA},
		se:  udprelay.ServerEndpoint{ServerDisco: serverDiscoB, LamportID: 1, VNI: 1},
	}

	tests := []struct {
		name   string
		events []newRelayServerEndpointEvent
		want   []newRelayServerEndpointEvent
	}{
		{
			// Test for http://go/corp/32978
			name: "eq-server-ep-neq-VNI-higher-lamport",
			events: []newRelayServerEndpointEvent{
				serverAendpointALamport1VNI1,
				serverAendpointALamport2VNI2,
			},
			want: []newRelayServerEndpointEvent{
				serverAendpointALamport2VNI2,
			},
		},
		{
			name: "eq-server-ep-neq-VNI-lower-lamport",
			events: []newRelayServerEndpointEvent{
				serverAendpointALamport2VNI2,
				serverAendpointALamport1VNI1,
			},
			want: []newRelayServerEndpointEvent{
				serverAendpointALamport2VNI2,
			},
		},
		{
			name: "eq-server-vni-neq-ep-lower-lamport",
			events: []newRelayServerEndpointEvent{
				serverAendpointALamport2VNI2,
				serverAendpointBLamport1VNI2,
			},
			want: []newRelayServerEndpointEvent{
				serverAendpointALamport2VNI2,
			},
		},
		{
			name: "eq-server-vni-neq-ep-higher-lamport",
			events: []newRelayServerEndpointEvent{
				serverAendpointBLamport1VNI2,
				serverAendpointALamport2VNI2,
			},
			want: []newRelayServerEndpointEvent{
				serverAendpointALamport2VNI2,
			},
		},
		{
			name: "eq-server-endpoint-vni-higher-lamport",
			events: []newRelayServerEndpointEvent{
				serverAendpointALamport1VNI1,
				serverAendpointALamport2VNI1,
			},
			want: []newRelayServerEndpointEvent{
				serverAendpointALamport2VNI1,
			},
		},
		{
			name: "eq-server-endpoint-vni-lower-lamport",
			events: []newRelayServerEndpointEvent{
				serverAendpointALamport2VNI1,
				serverAendpointALamport1VNI1,
			},
			want: []newRelayServerEndpointEvent{
				serverAendpointALamport2VNI1,
			},
		},
		{
			name: "eq-endpoint-vni-lamport-neq-server",
			events: []newRelayServerEndpointEvent{
				serverAendpointALamport1VNI1,
				serverBendpointALamport1VNI1,
			},
			want: []newRelayServerEndpointEvent{
				serverAendpointALamport1VNI1,
				serverBendpointALamport1VNI1,
			},
		},
		{
			// Trusted lastBest matching on both server disco and VNI suppresses
			// the new handshake.
			name: "trusted-last-best-matching-server-and-vni",
			events: []newRelayServerEndpointEvent{
				serverAendpointALamport1VNI1LastBestMatching,
			},
			want: []newRelayServerEndpointEvent{},
		},
		{
			// Trusted lastBest matching on server disco but NOT VNI must not
			// suppress the new handshake, otherwise we may never handshake a new
			// peer relay server endpoint around remote client restarts and/or
			// disco key rotation (#20215).
			name: "trusted-last-best-matching-server-neq-vni",
			events: []newRelayServerEndpointEvent{
				serverAendpointALamport1VNI1LastBestMatchingServerNeqVNI,
			},
			want: []newRelayServerEndpointEvent{
				serverAendpointALamport1VNI1LastBestMatchingServerNeqVNI,
			},
		},
		{
			// Trusted lastBest matching on server disco with an unset VNI must
			// not suppress the new handshake.
			name: "trusted-last-best-matching-server-unset-vni",
			events: []newRelayServerEndpointEvent{
				serverAendpointALamport1VNI1LastBestMatchingServerUnsetVNI,
			},
			want: []newRelayServerEndpointEvent{
				serverAendpointALamport1VNI1LastBestMatchingServerUnsetVNI,
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			rm := &relayManager{}
			rm.init()
			<-rm.runLoopStoppedCh // prevent runLoop() from starting

			// feed events
			for _, event := range tt.events {
				rm.handleNewServerEndpointRunLoop(event)
			}

			// validate state
			wantHandshakeWorkCount(t, rm, len(tt.want))
			for _, want := range tt.want {
				byServerDisco, ok := rm.handshakeWorkByServerDiscoByEndpoint[want.wlb.ep]
				if !ok {
					t.Fatal("work not found by endpoint")
				}
				workByServerDiscoByEndpoint, ok := byServerDisco[want.se.ServerDisco]
				if !ok {
					t.Fatal("work not found by server disco by endpoint")
				}
				workByServerDiscoVNI, ok := rm.handshakeWorkByServerDiscoVNI[serverDiscoVNI{want.se.ServerDisco, want.se.VNI}]
				if !ok {
					t.Fatal("work not found by server disco + VNI")
				}
				if workByServerDiscoByEndpoint != workByServerDiscoVNI {
					t.Fatal("workByServerDiscoByEndpoint != workByServerDiscoVNI")
				}
			}

			// cleanup
			for _, event := range tt.events {
				rm.stopWorkRunLoop(event.wlb.ep)
			}
			wantHandshakeWorkCount(t, rm, 0)
		})
	}
}
