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

package largetailnet_test

import (
	"context"
	"fmt"
	"net/http/httptest"
	"net/netip"
	"os"
	"path/filepath"
	"testing"
	"time"

	"tailscale.com/ipn/store/mem"
	"tailscale.com/tailcfg"
	"tailscale.com/tsnet"
	"tailscale.com/tstest"
	"tailscale.com/tstest/integration"
	"tailscale.com/tstest/integration/testcontrol"
	"tailscale.com/tstest/largetailnet"
	"tailscale.com/types/ipproto"
	"tailscale.com/types/logger"
	"tailscale.com/util/clientmetric"
	"tailscale.com/wgengine/filter"
)

// metricByName returns the [clientmetric.Metric] with the given name,
// failing the test if not found.
func metricByName(t testing.TB, name string) *clientmetric.Metric {
	t.Helper()
	for _, m := range clientmetric.Metrics() {
		if m.Name() == name {
			return m
		}
	}
	t.Fatalf("metric %q not found", name)
	return nil
}

// TestNetmapDeltaFastPath drives a sequence of MapResponses against an
// in-process tsnet + testcontrol harness via [largetailnet.Streamer]'s
// AltMapStream hook, exercising every delta-message kind the
// incremental netmap path handles. After each delta it asserts both:
//
//   - the appropriate fast-path metric counters incremented (i.e. we
//     stayed on the incremental path and did not fall through to a full
//     netmap rebuild); and
//
//   - the corresponding side effect is observable on the [LocalBackend]
//     (a fresh peer resolvable via PeerByID, a UserProfile resolvable
//     via UserProfile, a packet filter rule reflected in
//     GetFilterForTest, a per-field patch reflected in PeerByID, etc.).
//
// This is the destination-side companion to
// [tstest/largetailnet/BenchmarkGiantTailnet], which only measures cost
// of the same fast path — this test verifies correctness.
func TestNetmapDeltaFastPath(t *testing.T) {

	logf := logger.Discard
	if testing.Verbose() {
		logf = t.Logf
	}

	ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second)
	t.Cleanup(cancel)

	derpMap := integration.RunDERPAndSTUN(t, logf, "127.0.0.1")

	// Start with one initial peer (NodeID 2) so the initial netmap is
	// realistic. The fast path will not fire for the initial response —
	// it always goes through UpdateFullNetmap — but every subsequent
	// SendDelta should.
	streamer := largetailnet.New(1, derpMap)
	ctrl := &testcontrol.Server{
		DERPMap:      derpMap,
		DNSConfig:    &tailcfg.DNSConfig{},
		AltMapStream: streamer.AltMapStream(),
		Logf:         logf,
	}
	ctrl.HTTPTestServer = httptest.NewUnstartedServer(ctrl)
	ctrl.HTTPTestServer.Start()
	t.Cleanup(ctrl.HTTPTestServer.Close)

	tmp := filepath.Join(t.TempDir(), "tsnet")
	if err := os.MkdirAll(tmp, 0o755); err != nil {
		t.Fatal(err)
	}
	s := &tsnet.Server{
		Dir:        tmp,
		ControlURL: ctrl.HTTPTestServer.URL,
		Hostname:   "delta-test",
		Store:      new(mem.Store),
		Ephemeral:  true,
		Logf:       logf,
	}
	t.Cleanup(func() { s.Close() })
	if _, err := s.Up(ctx); err != nil {
		t.Fatalf("tsnet.Server.Up: %v", err)
	}
	lb := tsnet.TestHooks.LocalBackend(s)

	// Snapshot baseline metric values; we'll assert deltas against
	// these. Globals make per-test isolation impossible, but deltas
	// are robust against interleaving (assuming no other test runs in
	// parallel here).
	mFast := metricByName(t, "controlclient_map_response_handled_incrementally")
	mFull := metricByName(t, "controlclient_map_response_handled_full_rebuild")
	mUpsert := metricByName(t, "localbackend_netmap_delta_peer_upserted")
	mRem := metricByName(t, "localbackend_netmap_delta_peer_removed")
	mPatch := metricByName(t, "localbackend_netmap_delta_peer_patched")
	mFilter := metricByName(t, "localbackend_update_packet_filter")
	mUsers := metricByName(t, "localbackend_update_user_profiles")
	baseline := map[*clientmetric.Metric]int64{
		mFast: mFast.Value(), mFull: mFull.Value(),
		mUpsert: mUpsert.Value(), mRem: mRem.Value(), mPatch: mPatch.Value(),
		mFilter: mFilter.Value(), mUsers: mUsers.Value(),
	}
	dumpMetrics := func(t *testing.T) {
		t.Helper()
		for _, m := range []*clientmetric.Metric{mFast, mFull, mUpsert, mRem, mPatch, mFilter, mUsers} {
			t.Logf("metric %s = %d (baseline %d, delta %d)", m.Name(), m.Value(), baseline[m], m.Value()-baseline[m])
		}
	}
	waitDelta := func(t *testing.T, m *clientmetric.Metric, want int64) {
		t.Helper()
		err := tstest.WaitFor(2*time.Second, func() error {
			got := m.Value() - baseline[m]
			if got >= want {
				return nil
			}
			return fmt.Errorf("%s delta = %d, want >= %d", m.Name(), got, want)
		})
		if err != nil {
			dumpMetrics(t)
			t.Fatalf("%s: %v", m.Name(), err)
		}
		if got := m.Value() - baseline[m]; got != want {
			t.Errorf("%s delta = %d, want exactly %d", m.Name(), got, want)
		}
		baseline[m] = m.Value()
	}

	// Helper to send a MapResponse and wait for it to be processed by
	// the client. We use the metric deltas as our synchronization
	// point: SendDelta is synchronous from the streamer side, but the
	// client processes the response on its own goroutine, so we wait
	// for the fast-path counter to tick.
	sendDelta := func(t *testing.T, mr *tailcfg.MapResponse) {
		t.Helper()
		if err := streamer.SendDelta(ctx, mr); err != nil {
			t.Fatalf("SendDelta: %v", err)
		}
	}

	// Self IPv4, used as the destination in packet filter checks below.
	// largetailnet derives self addresses from SelfNodeID via node4/node6;
	// for SelfNodeID=1 that's 100.100.0.1.
	selfIP4 := netip.MustParseAddr("100.100.0.1")

	// addedPeerID is set by the peer_added_with_filter_and_user_profile
	// subtest and consumed later by peer_removed.
	var addedPeerID tailcfg.NodeID

	t.Run("peer_added_with_filter_and_user_profile", func(t *testing.T) {
		// Add a fresh peer. Bundle a new PacketFilter rule allowing
		// TCP from that peer's IP to a port we'll later probe, and a
		// new UserProfile for the user that owns the new peer.
		newPeer := streamer.AllocPeer()
		newUser := tailcfg.UserID(42)
		newPeer.User = newUser
		newPeer.Addresses = []netip.Prefix{netip.MustParsePrefix("100.64.0.42/32")}
		addedPeerID = newPeer.ID
		sendDelta(t, &tailcfg.MapResponse{
			PeersChanged: []*tailcfg.Node{newPeer},
			PacketFilter: []tailcfg.FilterRule{{
				SrcIPs:   []string{"100.64.0.42/32"},
				IPProto:  []int{int(ipproto.TCP)},
				DstPorts: []tailcfg.NetPortRange{{IP: "*", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
			}},
			UserProfiles: []tailcfg.UserProfile{{
				ID:          newUser,
				LoginName:   "alice@example.com",
				DisplayName: "Alice",
			}},
		})

		waitDelta(t, mFast, 1)
		waitDelta(t, mUpsert, 1)
		waitDelta(t, mFilter, 1)
		waitDelta(t, mUsers, 1)
		waitDelta(t, mFull, 0)

		// Side effects.
		nv, ok := lb.PeerByID(newPeer.ID)
		if !ok || nv.ID() != newPeer.ID {
			t.Errorf("PeerByID(%d) ok=%v node=%v", newPeer.ID, ok, nv)
		}
		uv, ok := lb.UserProfile(newUser)
		if !ok || uv.LoginName() != "alice@example.com" {
			t.Errorf("UserProfile(%d) ok=%v login=%q", newUser, ok, uv.LoginName())
		}
		pf := lb.GetFilterForTest()
		if got := pf.Check(netip.MustParseAddr("100.64.0.42"), selfIP4, 22, ipproto.TCP); got != filter.Accept {
			t.Errorf("packet filter Check from new peer = %s; want Accept", got)
		}
	})

	t.Run("peer_patch_derp_home", func(t *testing.T) {
		// Patch the initial peer's DERPRegion via PeersChangedPatch.
		// This rides as NodeMutationDERPHome.
		sendDelta(t, &tailcfg.MapResponse{
			PeersChangedPatch: []*tailcfg.PeerChange{{
				NodeID:     2,
				DERPRegion: 7,
			}},
		})

		waitDelta(t, mFast, 1)
		waitDelta(t, mPatch, 1)
		waitDelta(t, mFull, 0)

		nv, ok := lb.PeerByID(2)
		if !ok {
			t.Fatalf("PeerByID(2) not found")
		}
		if got := nv.HomeDERP(); got != 7 {
			t.Errorf("HomeDERP = %d, want 7", got)
		}
	})

	t.Run("peer_online_and_last_seen", func(t *testing.T) {
		// Online + LastSeen on the same delta. PeerSeenChange's value
		// is true to set LastSeen, false to clear it; the time it gets
		// is now() at the time MutationsFromMapResponse runs on the
		// client, not a wire value.
		sendDelta(t, &tailcfg.MapResponse{
			OnlineChange:   map[tailcfg.NodeID]bool{2: true},
			PeerSeenChange: map[tailcfg.NodeID]bool{2: true},
		})

		waitDelta(t, mFast, 1)
		// Two mutations: one NodeMutationOnline + one NodeMutationLastSeen.
		waitDelta(t, mPatch, 2)
		waitDelta(t, mFull, 0)

		nv, ok := lb.PeerByID(2)
		if !ok {
			t.Fatalf("PeerByID(2) not found")
		}
		if o := nv.Online(); !o.Valid() || !o.Get() {
			t.Errorf("Online = %v, want true", o)
		}
	})

	t.Run("peer_removed", func(t *testing.T) {
		if addedPeerID == 0 {
			t.Fatal("peer_added_with_filter_and_user_profile must run first")
		}
		// Sanity check: the peer should currently exist.
		if _, ok := lb.PeerByID(addedPeerID); !ok {
			t.Fatalf("PeerByID(%d) missing before removal", addedPeerID)
		}

		sendDelta(t, &tailcfg.MapResponse{
			PeersRemoved: []tailcfg.NodeID{addedPeerID},
		})

		waitDelta(t, mFast, 1)
		waitDelta(t, mRem, 1)
		waitDelta(t, mFull, 0)

		if _, ok := lb.PeerByID(addedPeerID); ok {
			t.Errorf("PeerByID(%d) still present after PeersRemoved", addedPeerID)
		}
	})
}
