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

package ipnlocal

import (
	"fmt"
	"os/user"
	"strconv"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	_ "tailscale.com/clientupdate" // for feature registration side effects
	"tailscale.com/feature"
	"tailscale.com/health"
	"tailscale.com/ipn"
	"tailscale.com/ipn/store/mem"
	"tailscale.com/tailcfg"
	"tailscale.com/types/key"
	"tailscale.com/types/logger"
	"tailscale.com/types/persist"
	"tailscale.com/util/eventbus/eventbustest"
	"tailscale.com/util/must"
)

func TestProfileCurrentUserSwitch(t *testing.T) {
	store := new(mem.Store)

	pm, err := newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
	if err != nil {
		t.Fatal(err)
	}
	id := 0
	newProfile := func(t *testing.T, loginName string) ipn.PrefsView {
		id++
		t.Helper()
		pm.SwitchToNewProfile()
		p := pm.CurrentPrefs().AsStruct()
		p.Persist = &persist.Persist{
			NodeID:         tailcfg.StableNodeID(fmt.Sprint(id)),
			PrivateNodeKey: key.NewNode(),
			UserProfile: tailcfg.UserProfile{
				ID:        tailcfg.UserID(id),
				LoginName: loginName,
			},
		}
		if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
			t.Fatal(err)
		}
		return p.View()
	}

	pm.SetCurrentUserID("user1")
	newProfile(t, "user1")
	cp := pm.currentProfile
	pm.DeleteProfile(cp.ID())
	if !pm.currentProfile.Valid() {
		t.Fatal("currentProfile is nil")
	} else if pm.currentProfile.ID() != "" {
		t.Fatalf("currentProfile.ID = %q, want empty", pm.currentProfile.ID())
	}
	if !pm.CurrentPrefs().Equals(defaultPrefs) {
		t.Fatalf("CurrentPrefs() = %v, want emptyPrefs", pm.CurrentPrefs().Pretty())
	}

	pm, err = newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
	if err != nil {
		t.Fatal(err)
	}
	pm.SetCurrentUserID("user1")
	if !pm.currentProfile.Valid() {
		t.Fatal("currentProfile is nil")
	} else if pm.currentProfile.ID() != "" {
		t.Fatalf("currentProfile.ID = %q, want empty", pm.currentProfile.ID())
	}
	if !pm.CurrentPrefs().Equals(defaultPrefs) {
		t.Fatalf("CurrentPrefs() = %v, want emptyPrefs", pm.CurrentPrefs().Pretty())
	}
}

func TestProfileList(t *testing.T) {
	store := new(mem.Store)

	pm, err := newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
	if err != nil {
		t.Fatal(err)
	}
	id := 0
	newProfile := func(t *testing.T, loginName string) ipn.PrefsView {
		id++
		t.Helper()
		pm.SwitchToNewProfile()
		p := pm.CurrentPrefs().AsStruct()
		p.Persist = &persist.Persist{
			NodeID:         tailcfg.StableNodeID(fmt.Sprint(id)),
			PrivateNodeKey: key.NewNode(),
			UserProfile: tailcfg.UserProfile{
				ID:        tailcfg.UserID(id),
				LoginName: loginName,
			},
		}
		if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
			t.Fatal(err)
		}
		return p.View()
	}
	checkProfiles := func(t *testing.T, want ...string) {
		t.Helper()
		got := pm.Profiles()
		if len(got) != len(want) {
			t.Fatalf("got %d profiles, want %d", len(got), len(want))
		}
		for i, w := range want {
			if got[i].Name() != w {
				t.Errorf("got profile %d name %q, want %q", i, got[i].Name(), w)
			}
		}
	}

	pm.SetCurrentUserID("user1")
	newProfile(t, "alice")
	newProfile(t, "bob")
	checkProfiles(t, "alice", "bob")

	pm.SetCurrentUserID("user2")
	checkProfiles(t)
	newProfile(t, "carol")
	carol := pm.currentProfile
	checkProfiles(t, "carol")

	pm.SetCurrentUserID("user1")
	checkProfiles(t, "alice", "bob")
	if lp := pm.findProfileByKey("user1", carol.Key()); lp.Valid() {
		t.Fatalf("found profile for user2 in user1's profile list")
	}
	if lp := pm.findProfileByName("user1", carol.Name()); lp.Valid() {
		t.Fatalf("found profile for user2 in user1's profile list")
	}

	pm.SetCurrentUserID("user2")
	checkProfiles(t, "carol")
}

func TestProfileDupe(t *testing.T) {
	newPersist := func(user, node int) *persist.Persist {
		return &persist.Persist{
			NodeID: tailcfg.StableNodeID(fmt.Sprintf("node%d", node)),
			UserProfile: tailcfg.UserProfile{
				ID:        tailcfg.UserID(user),
				LoginName: fmt.Sprintf("user%d@example.com", user),
			},
		}
	}
	user1Node1 := newPersist(1, 1)
	user1Node2 := newPersist(1, 2)
	user2Node1 := newPersist(2, 1)
	user2Node2 := newPersist(2, 2)
	user3Node3 := newPersist(3, 3)

	reauth := func(pm *profileManager, p *persist.Persist) {
		prefs := ipn.NewPrefs()
		prefs.Persist = p
		must.Do(pm.SetPrefs(prefs.View(), ipn.NetworkProfile{}))
	}
	login := func(pm *profileManager, p *persist.Persist) {
		pm.SwitchToNewProfile()
		reauth(pm, p)
	}

	type step struct {
		fn func(pm *profileManager, p *persist.Persist)
		p  *persist.Persist
	}

	tests := []struct {
		name  string
		steps []step
		profs []*persist.Persist
	}{
		{
			name: "reauth-new-node",
			steps: []step{
				{login, user1Node1},
				{reauth, user3Node3},
			},
			profs: []*persist.Persist{
				user3Node3,
			},
		},
		{
			name: "reauth-same-node",
			steps: []step{
				{login, user1Node1},
				{reauth, user1Node1},
			},
			profs: []*persist.Persist{
				user1Node1,
			},
		},
		{
			name: "reauth-other-profile",
			steps: []step{
				{login, user1Node1},
				{login, user2Node2},
				{reauth, user1Node1},
			},
			profs: []*persist.Persist{
				user1Node1,
				user2Node2,
			},
		},
		{
			name: "reauth-replace-user",
			steps: []step{
				{login, user1Node1},
				{login, user3Node3},
				{reauth, user2Node1},
			},
			profs: []*persist.Persist{
				user2Node1,
				user3Node3,
			},
		},
		{
			name: "reauth-replace-node",
			steps: []step{
				{login, user1Node1},
				{login, user3Node3},
				{reauth, user1Node2},
			},
			profs: []*persist.Persist{
				user1Node2,
				user3Node3,
			},
		},
		{
			name: "login-same-node",
			steps: []step{
				{login, user1Node1},
				{login, user3Node3}, // random other profile
				{login, user1Node1},
			},
			profs: []*persist.Persist{
				user1Node1,
				user3Node3,
			},
		},
		{
			name: "login-replace-user",
			steps: []step{
				{login, user1Node1},
				{login, user3Node3}, // random other profile
				{login, user2Node1},
			},
			profs: []*persist.Persist{
				user2Node1,
				user3Node3,
			},
		},
		{
			name: "login-replace-node",
			steps: []step{
				{login, user1Node1},
				{login, user3Node3}, // random other profile
				{login, user1Node2},
			},
			profs: []*persist.Persist{
				user1Node2,
				user3Node3,
			},
		},
		{
			name: "login-new-node",
			steps: []step{
				{login, user1Node1},
				{login, user2Node2},
			},
			profs: []*persist.Persist{
				user1Node1,
				user2Node2,
			},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			store := new(mem.Store)
			pm, err := newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
			if err != nil {
				t.Fatal(err)
			}
			for _, s := range tc.steps {
				s.fn(pm, s.p)
			}
			profs := pm.Profiles()
			var got []*persist.Persist
			for _, p := range profs {
				prefs, err := pm.loadSavedPrefs(p.Key())
				if err != nil {
					t.Fatal(err)
				}
				got = append(got, prefs.Persist().AsStruct())
			}
			d := cmp.Diff(tc.profs, got, cmpopts.SortSlices(func(a, b *persist.Persist) bool {
				if a.NodeID != b.NodeID {
					return a.NodeID < b.NodeID
				}
				return a.UserProfile.ID < b.UserProfile.ID
			}))
			if d != "" {
				t.Fatal(d)
			}
		})
	}
}

// TestProfileManagement tests creating, loading, and switching profiles.
func TestProfileManagement(t *testing.T) {
	store := new(mem.Store)

	pm, err := newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
	if err != nil {
		t.Fatal(err)
	}
	wantCurProfile := ""
	wantProfiles := map[string]ipn.PrefsView{
		"": defaultPrefs,
	}
	checkProfiles := func(t *testing.T) {
		t.Helper()
		prof := pm.CurrentProfile()
		t.Logf("\tCurrentProfile = %q", prof.Name())
		if prof.Name() != wantCurProfile {
			t.Fatalf("CurrentProfile = %q; want %q", prof.Name(), wantCurProfile)
		}
		profiles := pm.Profiles()
		wantLen := len(wantProfiles)
		if _, ok := wantProfiles[""]; ok {
			wantLen--
		}
		if len(profiles) != wantLen {
			t.Fatalf("Profiles = %v; want %v", profiles, wantProfiles)
		}
		p := pm.CurrentPrefs()
		t.Logf("\tCurrentPrefs = %s", p.Pretty())
		if !p.Valid() {
			t.Fatalf("CurrentPrefs = %v; want valid", p)
		}
		if !p.Equals(wantProfiles[wantCurProfile]) {
			t.Fatalf("CurrentPrefs = %v; want %v", p.Pretty(), wantProfiles[wantCurProfile].Pretty())
		}
		for _, p := range profiles {
			got, err := pm.loadSavedPrefs(p.Key())
			if err != nil {
				t.Fatal(err)
			}
			// Use Hostname as a proxy for all prefs.
			if !got.Equals(wantProfiles[p.Name()]) {
				t.Fatalf("Prefs for profile %q =\n got=%+v\nwant=%v", p.Name(), got.Pretty(), wantProfiles[p.Name()].Pretty())
			}
		}
	}
	logins := make(map[string]tailcfg.UserID)
	nodeIDs := make(map[string]tailcfg.StableNodeID)
	setPrefs := func(t *testing.T, loginName string) ipn.PrefsView {
		t.Helper()
		p := pm.CurrentPrefs().AsStruct()
		uid := logins[loginName]
		if uid.IsZero() {
			uid = tailcfg.UserID(len(logins) + 1)
			logins[loginName] = uid
		}
		nid := nodeIDs[loginName]
		if nid.IsZero() {
			nid = tailcfg.StableNodeID(fmt.Sprint(len(nodeIDs) + 1))
			nodeIDs[loginName] = nid
		}
		p.Persist = &persist.Persist{
			PrivateNodeKey: key.NewNode(),
			UserProfile: tailcfg.UserProfile{
				ID:        uid,
				LoginName: loginName,
			},
			NodeID: nid,
		}
		if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
			t.Fatal(err)
		}
		return p.View()
	}
	t.Logf("Check initial state from empty store")
	checkProfiles(t)

	{
		t.Logf("Set prefs for default profile")
		wantProfiles["user@1.example.com"] = setPrefs(t, "user@1.example.com")
		wantCurProfile = "user@1.example.com"
		delete(wantProfiles, "")
	}
	checkProfiles(t)

	t.Logf("Create new profile")
	pm.SwitchToNewProfile()
	wantCurProfile = ""
	wantProfiles[""] = defaultPrefs
	checkProfiles(t)

	{
		t.Logf("Set prefs for test profile")
		wantProfiles["user@2.example.com"] = setPrefs(t, "user@2.example.com")
		wantCurProfile = "user@2.example.com"
		delete(wantProfiles, "")
	}
	checkProfiles(t)

	t.Logf("Recreate profile manager from store")
	// Recreate the profile manager to ensure that it can load the profiles
	// from the store at startup.
	pm, err = newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
	if err != nil {
		t.Fatal(err)
	}
	checkProfiles(t)

	t.Logf("Delete default profile")
	if err := pm.DeleteProfile(pm.ProfileIDForName("user@1.example.com")); err != nil {
		t.Fatal(err)
	}
	delete(wantProfiles, "user@1.example.com")
	checkProfiles(t)

	t.Logf("Recreate profile manager from store after deleting default profile")
	// Recreate the profile manager to ensure that it can load the profiles
	// from the store at startup.
	pm, err = newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
	if err != nil {
		t.Fatal(err)
	}
	checkProfiles(t)

	t.Logf("Create new profile - 2")
	pm.SwitchToNewProfile()
	wantCurProfile = ""
	wantProfiles[""] = defaultPrefs
	checkProfiles(t)

	t.Logf("Login with the existing profile")
	wantProfiles["user@2.example.com"] = setPrefs(t, "user@2.example.com")
	delete(wantProfiles, "")
	wantCurProfile = "user@2.example.com"
	checkProfiles(t)

	t.Logf("Tag the current the profile")
	nodeIDs["tagged-node.2.ts.net"] = nodeIDs["user@2.example.com"]
	wantProfiles["tagged-node.2.ts.net"] = setPrefs(t, "tagged-node.2.ts.net")
	delete(wantProfiles, "user@2.example.com")
	wantCurProfile = "tagged-node.2.ts.net"
	checkProfiles(t)

	t.Logf("Relogin")
	wantProfiles["user@2.example.com"] = setPrefs(t, "user@2.example.com")
	delete(wantProfiles, "tagged-node.2.ts.net")
	wantCurProfile = "user@2.example.com"
	checkProfiles(t)

	if !feature.CanAutoUpdate() {
		t.Logf("Save an invalid AutoUpdate pref value")
		prefs := pm.CurrentPrefs().AsStruct()
		prefs.AutoUpdate.Apply.Set(true)
		if err := pm.SetPrefs(prefs.View(), ipn.NetworkProfile{}); err != nil {
			t.Fatal(err)
		}
		if !pm.CurrentPrefs().AutoUpdate().Apply.EqualBool(true) {
			t.Fatal("SetPrefs failed to save auto-update setting")
		}
		// Re-load profiles to trigger migration for invalid auto-update value.
		pm, err = newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
		if err != nil {
			t.Fatal(err)
		}
		checkProfiles(t)
		if pm.CurrentPrefs().AutoUpdate().Apply.EqualBool(true) {
			t.Fatal("invalid auto-update setting persisted after reload")
		}
	}
}

// TestProfileManagementWindows tests going into and out of Unattended mode on
// Windows.
func TestProfileManagementWindows(t *testing.T) {
	u, err := user.Current()
	if err != nil {
		t.Fatal(err)
	}
	uid := ipn.WindowsUserID(u.Uid)

	store := new(mem.Store)

	pm, err := newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "windows")
	if err != nil {
		t.Fatal(err)
	}
	wantCurProfile := ""
	wantProfiles := map[string]ipn.PrefsView{
		"": defaultPrefs,
	}
	checkProfiles := func(t *testing.T) {
		t.Helper()
		prof := pm.CurrentProfile()
		t.Logf("\tCurrentProfile = %q", prof.Name())
		if prof.Name() != wantCurProfile {
			t.Fatalf("CurrentProfile = %q; want %q", prof.Name(), wantCurProfile)
		}
		if p := pm.CurrentPrefs(); !p.Equals(wantProfiles[wantCurProfile]) {
			t.Fatalf("CurrentPrefs = %+v; want %+v", p.Pretty(), wantProfiles[wantCurProfile].Pretty())
		}
	}
	logins := make(map[string]tailcfg.UserID)
	setPrefs := func(t *testing.T, loginName string, forceDaemon bool) ipn.PrefsView {
		id := logins[loginName]
		if id.IsZero() {
			id = tailcfg.UserID(len(logins) + 1)
			logins[loginName] = id
		}
		p := pm.CurrentPrefs().AsStruct()
		p.ForceDaemon = forceDaemon
		p.Persist = &persist.Persist{
			UserProfile: tailcfg.UserProfile{
				ID:        id,
				LoginName: loginName,
			},
			NodeID: tailcfg.StableNodeID(strconv.Itoa(int(id))),
		}
		if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
			t.Fatal(err)
		}
		return p.View()
	}
	t.Logf("Check initial state from empty store")
	checkProfiles(t)

	{
		t.Logf("Set user1 as logged in user")
		pm.SetCurrentUserID(uid)
		checkProfiles(t)
		t.Logf("Save prefs for user1")
		wantProfiles["default"] = setPrefs(t, "default", false)
		wantCurProfile = "default"
	}
	checkProfiles(t)

	{
		t.Logf("Create new profile")
		pm.SwitchToNewProfile()
		wantCurProfile = ""
		wantProfiles[""] = defaultPrefs
		checkProfiles(t)

		t.Logf("Save as test profile")
		wantProfiles["test"] = setPrefs(t, "test", false)
		wantCurProfile = "test"
		checkProfiles(t)
	}

	t.Logf("Recreate profile manager from store, should reset prefs")
	// Recreate the profile manager to ensure that it can load the profiles
	// from the store at startup.
	pm, err = newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "windows")
	if err != nil {
		t.Fatal(err)
	}
	wantCurProfile = ""
	wantProfiles[""] = defaultPrefs
	checkProfiles(t)

	{
		t.Logf("Set user1 as current user")
		pm.SetCurrentUserID(uid)
		wantCurProfile = "test"
	}
	checkProfiles(t)
	{
		t.Logf("set unattended mode")
		wantProfiles["test"] = setPrefs(t, "test", true)
	}
	if pm.CurrentUserID() != uid {
		t.Fatalf("CurrentUserID = %q; want %q", pm.CurrentUserID(), uid)
	}

	// Recreate the profile manager to ensure that it starts with test profile.
	pm, err = newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "windows")
	if err != nil {
		t.Fatal(err)
	}
	checkProfiles(t)
	if pm.CurrentUserID() != uid {
		t.Fatalf("CurrentUserID = %q; want %q", pm.CurrentUserID(), uid)
	}
}

// TestDefaultPrefs tests that defaultPrefs is just NewPrefs with
// LoggedOut=true (the Prefs we use before connecting to control). We shouldn't
// be putting any defaulting there, and instead put all defaults in NewPrefs.
func TestDefaultPrefs(t *testing.T) {
	p1 := ipn.NewPrefs()
	p1.LoggedOut = true
	p1.WantRunning = false
	p2 := defaultPrefs
	if !p1.View().Equals(p2) {
		t.Errorf("defaultPrefs is %s, want %s; defaultPrefs should only modify WantRunning and LoggedOut, all other defaults should be in ipn.NewPrefs.", p2.Pretty(), p1.Pretty())
	}
}

// mutPrefsFn is a function that mutates the prefs.
// Deserialization pre‑populates prefs with default (non‑zero) values.
// After saving prefs and reading them back, we may not get exactly what we set.
// For this reason, tests apply changes through a helper that mutates
// [ipn.NewPrefs] instead of hard‑coding expected values in each case.
type mutPrefsFn func(*ipn.Prefs)

type profileState struct {
	*ipn.LoginProfile
	mutPrefs mutPrefsFn
}

func (s *profileState) prefs() ipn.PrefsView {
	prefs := ipn.NewPrefs() // apply changes to the default prefs
	s.mutPrefs(prefs)
	return prefs.View()
}

type profileStateChange struct {
	*ipn.LoginProfile
	mutPrefs mutPrefsFn
	sameNode bool
}

func wantProfileChange(state profileState) profileStateChange {
	return profileStateChange{
		LoginProfile: state.LoginProfile,
		mutPrefs:     state.mutPrefs,
		sameNode:     false,
	}
}

func wantPrefsChange(state profileState) profileStateChange {
	return profileStateChange{
		LoginProfile: state.LoginProfile,
		mutPrefs:     state.mutPrefs,
		sameNode:     true,
	}
}

func makeDefaultPrefs(p *ipn.Prefs) { *p = *defaultPrefs.AsStruct() }

func makeKnownProfileState(id int, nameSuffix string, uid ipn.WindowsUserID, mutPrefs mutPrefsFn) profileState {
	lowerNameSuffix := strings.ToLower(nameSuffix)
	nid := "node-" + tailcfg.StableNodeID(lowerNameSuffix)
	up := tailcfg.UserProfile{
		ID:          tailcfg.UserID(id),
		LoginName:   fmt.Sprintf("user-%s@example.com", lowerNameSuffix),
		DisplayName: "User " + nameSuffix,
	}
	return profileState{
		LoginProfile: &ipn.LoginProfile{
			LocalUserID: uid,
			Name:        up.LoginName,
			ID:          ipn.ProfileID(fmt.Sprintf("%04X", id)),
			Key:         "profile-" + ipn.StateKey(nameSuffix),
			NodeID:      nid,
			UserProfile: up,
		},
		mutPrefs: func(p *ipn.Prefs) {
			p.Hostname = "Hostname-" + nameSuffix
			if mutPrefs != nil {
				mutPrefs(p) // apply any additional changes
			}
			p.Persist = &persist.Persist{NodeID: nid, UserProfile: up}
		},
	}
}

func TestProfileStateChangeCallback(t *testing.T) {
	t.Parallel()

	// A few well-known profiles to use in tests.
	emptyProfile := profileState{
		LoginProfile: &ipn.LoginProfile{},
		mutPrefs:     makeDefaultPrefs,
	}
	profile0000 := profileState{
		LoginProfile: &ipn.LoginProfile{ID: "0000", Key: "profile-0000"},
		mutPrefs:     makeDefaultPrefs,
	}
	profileA := makeKnownProfileState(0xA, "A", "", nil)
	profileB := makeKnownProfileState(0xB, "B", "", nil)
	profileC := makeKnownProfileState(0xC, "C", "", nil)

	aliceUserID := ipn.WindowsUserID("S-1-5-21-1-2-3-4")
	aliceEmptyProfile := profileState{
		LoginProfile: &ipn.LoginProfile{LocalUserID: aliceUserID},
		mutPrefs:     makeDefaultPrefs,
	}
	bobUserID := ipn.WindowsUserID("S-1-5-21-3-4-5-6")
	bobEmptyProfile := profileState{
		LoginProfile: &ipn.LoginProfile{LocalUserID: bobUserID},
		mutPrefs:     makeDefaultPrefs,
	}
	bobKnownProfile := makeKnownProfileState(0xB0B, "Bob", bobUserID, nil)

	tests := []struct {
		name          string
		initial       *profileState         // if non-nil, this is the initial profile and prefs to start wit
		knownProfiles []profileState        // known profiles we can switch to
		action        func(*profileManager) // action to take on the profile manager
		wantChanges   []profileStateChange  // expected state changes
	}{
		{
			name: "no-changes",
			action: func(*profileManager) {
				// do nothing
			},
			wantChanges: nil,
		},
		{
			name: "no-initial/new-profile",
			action: func(pm *profileManager) {
				// The profile manager is new and started with a new empty profile.
				// This should not trigger a state change callback.
				pm.SwitchToNewProfile()
			},
			wantChanges: nil,
		},
		{
			name: "no-initial/new-profile-for-user",
			action: func(pm *profileManager) {
				// But switching to a new profile for a specific user should trigger
				// a state change callback.
				pm.SwitchToNewProfileForUser(aliceUserID)
			},
			wantChanges: []profileStateChange{
				// We want a new empty profile (owned by the specified user)
				// and the default prefs.
				wantProfileChange(aliceEmptyProfile),
			},
		},
		{
			name:    "with-initial/new-profile",
			initial: &profile0000,
			action: func(pm *profileManager) {
				// And so does switching to a new profile when the initial profile
				// is non-empty.
				pm.SwitchToNewProfile()
			},
			wantChanges: []profileStateChange{
				// We want a new empty profile and the default prefs.
				wantProfileChange(emptyProfile),
			},
		},
		{
			name:    "with-initial/new-profile/twice",
			initial: &profile0000,
			action: func(pm *profileManager) {
				// If we switch to a new profile twice, we should only get one state change.
				pm.SwitchToNewProfile()
				pm.SwitchToNewProfile()
			},
			wantChanges: []profileStateChange{
				// We want a new empty profile and the default prefs.
				wantProfileChange(emptyProfile),
			},
		},
		{
			name:    "with-initial/new-profile-for-user/twice",
			initial: &profile0000,
			action: func(pm *profileManager) {
				// Unless we switch to a new profile for a specific user,
				// in which case we should get a state change twice.
				pm.SwitchToNewProfileForUser(aliceUserID)
				pm.SwitchToNewProfileForUser(aliceUserID) // no change here
				pm.SwitchToNewProfileForUser(bobUserID)
			},
			wantChanges: []profileStateChange{
				// Both profiles are empty, but they are owned by different users.
				wantProfileChange(aliceEmptyProfile),
				wantProfileChange(bobEmptyProfile),
			},
		},
		{
			name:    "with-initial/new-profile/twice/with-prefs-change",
			initial: &profile0000,
			action: func(pm *profileManager) {
				// Or unless we switch to a new profile, change the prefs,
				// then switch to a new profile again. Since the current
				// profile is not empty after the prefs change, we should
				// get state changes for all three actions.
				pm.SwitchToNewProfile()
				p := pm.CurrentPrefs().AsStruct()
				p.WantRunning = true
				pm.SetPrefs(p.View(), ipn.NetworkProfile{})
				pm.SwitchToNewProfile()
			},
			wantChanges: []profileStateChange{
				wantProfileChange(emptyProfile), // new empty profile
				wantPrefsChange(profileState{ // prefs change, same profile
					LoginProfile: &ipn.LoginProfile{},
					mutPrefs: func(p *ipn.Prefs) {
						*p = *defaultPrefs.AsStruct()
						p.WantRunning = true
					},
				}),
				wantProfileChange(emptyProfile), // new empty profile again
			},
		},
		{
			name:          "switch-to-profile/by-id",
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				// Switching to a known profile by ID should trigger a state change callback.
				pm.SwitchToProfileByID(profileB.ID)
			},
			wantChanges: []profileStateChange{
				wantProfileChange(profileB),
			},
		},
		{
			name:          "switch-to-profile/by-id/non-existent",
			knownProfiles: []profileState{profileA, profileC}, // no profileB
			action: func(pm *profileManager) {
				// Switching to a non-existent profile should fail and not trigger a state change callback.
				pm.SwitchToProfileByID(profileB.ID)
			},
			wantChanges: []profileStateChange{},
		},
		{
			name:          "switch-to-profile/by-id/twice-same",
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				// But only for the first switch.
				// The second switch to the same profile should not trigger a state change callback.
				pm.SwitchToProfileByID(profileB.ID)
				pm.SwitchToProfileByID(profileB.ID)
			},
			wantChanges: []profileStateChange{
				wantProfileChange(profileB),
			},
		},
		{
			name:          "switch-to-profile/by-id/many",
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				// Same idea, but with multiple switches.
				pm.SwitchToProfileByID(profileB.ID) // switch to Profile-B
				pm.SwitchToProfileByID(profileB.ID) // then to Profile-B again (no change)
				pm.SwitchToProfileByID(profileC.ID) // then to Profile-C (change)
				pm.SwitchToProfileByID(profileA.ID) // then to Profile-A (change)
				pm.SwitchToProfileByID(profileB.ID) // then to Profile-B (change)
			},
			wantChanges: []profileStateChange{
				wantProfileChange(profileB),
				wantProfileChange(profileC),
				wantProfileChange(profileA),
				wantProfileChange(profileB),
			},
		},
		{
			name:          "switch-to-profile/by-view",
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				// Switching to a known profile by an [ipn.LoginProfileView]
				// should also trigger a state change callback.
				pm.SwitchToProfile(profileB.View())
			},
			wantChanges: []profileStateChange{
				wantProfileChange(profileB),
			},
		},
		{
			name:    "switch-to-profile/by-view/empty",
			initial: &profile0000,
			action: func(pm *profileManager) {
				// SwitchToProfile supports switching to an empty profile.
				emptyProfile := &ipn.LoginProfile{}
				pm.SwitchToProfile(emptyProfile.View())
			},
			wantChanges: []profileStateChange{
				wantProfileChange(emptyProfile),
			},
		},
		{
			name:          "switch-to-profile/by-view/non-existent",
			knownProfiles: []profileState{profileA, profileC},
			action: func(pm *profileManager) {
				// Switching to a an unknown profile by an [ipn.LoginProfileView]
				// should fail and not trigger a state change callback.
				pm.SwitchToProfile(profileB.View())
			},
			wantChanges: []profileStateChange{},
		},
		{
			name:    "switch-to-profile/by-view/empty-for-user",
			initial: &profile0000,
			action: func(pm *profileManager) {
				// And switching to an empty profile for a specific user also works.
				pm.SwitchToProfile(bobEmptyProfile.View())
			},
			wantChanges: []profileStateChange{
				wantProfileChange(bobEmptyProfile),
			},
		},
		{
			name:    "switch-to-profile/by-view/invalid",
			initial: &profile0000,
			action: func(pm *profileManager) {
				// Switching to an invalid profile should create and switch
				// to a new empty profile.
				pm.SwitchToProfile(ipn.LoginProfileView{})
			},
			wantChanges: []profileStateChange{
				wantProfileChange(emptyProfile),
			},
		},
		{
			name:          "delete-profile/current",
			initial:       &profileA, // profileA is the current profile
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				// Deleting the current profile should switch to a new empty profile.
				pm.DeleteProfile(profileA.ID)
			},
			wantChanges: []profileStateChange{
				wantProfileChange(emptyProfile),
			},
		},
		{
			name:          "delete-profile/current-with-user",
			initial:       &bobKnownProfile,
			knownProfiles: []profileState{profileA, profileB, profileC, bobKnownProfile},
			action: func(pm *profileManager) {
				// Similarly, deleting the current profile for a specific user should switch
				// to a new empty profile for that user (at least while the "current user"
				// is still a thing on Windows).
				pm.DeleteProfile(bobKnownProfile.ID)
			},
			wantChanges: []profileStateChange{
				wantProfileChange(bobEmptyProfile),
			},
		},
		{
			name:          "delete-profile/non-current",
			initial:       &profileA, // profileA is the current profile
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				// But deleting a non-current profile should not trigger a state change callback.
				pm.DeleteProfile(profileB.ID)
			},
			wantChanges: []profileStateChange{},
		},
		{
			name:    "set-prefs/new-profile",
			initial: &emptyProfile, // the current profile is empty
			action: func(pm *profileManager) {
				// The current profile is new and empty, but we can still set p.
				// This should trigger a state change callback.
				p := pm.CurrentPrefs().AsStruct()
				p.WantRunning = true
				p.Hostname = "New-Hostname"
				pm.SetPrefs(p.View(), ipn.NetworkProfile{})
			},
			wantChanges: []profileStateChange{
				// Still an empty profile, but with new prefs.
				wantPrefsChange(profileState{
					LoginProfile: emptyProfile.LoginProfile,
					mutPrefs: func(p *ipn.Prefs) {
						*p = *emptyProfile.prefs().AsStruct()
						p.WantRunning = true
						p.Hostname = "New-Hostname"
					},
				}),
			},
		},
		{
			name:          "set-prefs/current-profile",
			initial:       &profileA, // profileA is the current profile
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				p := pm.CurrentPrefs().AsStruct()
				p.WantRunning = true
				p.Hostname = "New-Hostname"
				pm.SetPrefs(p.View(), ipn.NetworkProfile{})
			},
			wantChanges: []profileStateChange{
				wantPrefsChange(profileState{
					LoginProfile: profileA.LoginProfile, // same profile
					mutPrefs: func(p *ipn.Prefs) { // but with new prefs
						*p = *profileA.prefs().AsStruct()
						p.WantRunning = true
						p.Hostname = "New-Hostname"
					},
				}),
			},
		},
		{
			name:          "set-prefs/current-profile/profile-name",
			initial:       &profileA, // profileA is the current profile
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				p := pm.CurrentPrefs().AsStruct()
				p.ProfileName = "This is User A"
				pm.SetPrefs(p.View(), ipn.NetworkProfile{})
			},
			wantChanges: []profileStateChange{
				// Still the same profile, but with a new profile name
				// populated from the prefs. The prefs are also updated.
				wantPrefsChange(profileState{
					LoginProfile: func() *ipn.LoginProfile {
						p := profileA.Clone()
						p.Name = "This is User A"
						return p
					}(),
					mutPrefs: func(p *ipn.Prefs) {
						*p = *profileA.prefs().AsStruct()
						p.ProfileName = "This is User A"
					},
				}),
			},
		},
		{
			name:          "set-prefs/implicit-switch/from-new",
			initial:       &emptyProfile, // a new, empty profile
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				// The user attempted to add a new profile but actually logged in as the same
				// node/user as profileB. When [LocalBackend.SetControlClientStatus] calls
				// [profileManager.SetPrefs] with the [persist.Persist] for profileB, we
				// implicitly switch to that profile instead of creating a duplicate for the
				// same node/user.
				//
				// TODO(nickkhyl): currently, [LocalBackend.SetControlClientStatus] uses the p
				// of the current profile, not those of the profile we switch to. This is all wrong
				// and should be fixed. But for now, we just test that the state change callback
				// is called with the new profile and p.
				p := pm.CurrentPrefs().AsStruct()
				p.Persist = profileB.prefs().Persist().AsStruct()
				p.WantRunning = true
				p.LoggedOut = false
				pm.SetPrefs(p.View(), ipn.NetworkProfile{})
			},
			wantChanges: []profileStateChange{
				// Calling [profileManager.SetPrefs] like this is effectively a profile switch
				// rather than a prefs change.
				wantProfileChange(profileState{
					LoginProfile: profileB.LoginProfile,
					mutPrefs: func(p *ipn.Prefs) {
						*p = *emptyProfile.prefs().AsStruct()
						p.Persist = profileB.prefs().Persist().AsStruct()
						p.WantRunning = true
						p.LoggedOut = false
					},
				}),
			},
		},
		{
			name:          "set-prefs/implicit-switch/from-other",
			initial:       &profileA, // profileA is the current profile
			knownProfiles: []profileState{profileA, profileB, profileC},
			action: func(pm *profileManager) {
				// Same idea, but the current profile is profileA rather than a new empty profile.
				// Note: this is all wrong. See the comment above and [profileManager.SetPrefs].
				p := pm.CurrentPrefs().AsStruct()
				p.Persist = profileB.prefs().Persist().AsStruct()
				p.WantRunning = true
				p.LoggedOut = false
				pm.SetPrefs(p.View(), ipn.NetworkProfile{})
			},
			wantChanges: []profileStateChange{
				wantProfileChange(profileState{
					LoginProfile: profileB.LoginProfile,
					mutPrefs: func(p *ipn.Prefs) {
						*p = *profileA.prefs().AsStruct()
						p.Persist = profileB.prefs().Persist().AsStruct()
						p.WantRunning = true
						p.LoggedOut = false
					},
				}),
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			store := new(mem.Store)
			pm, err := newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
			if err != nil {
				t.Fatalf("newProfileManagerWithGOOS: %v", err)
			}
			for _, p := range tt.knownProfiles {
				pm.writePrefsToStore(p.Key, p.prefs())
				pm.knownProfiles[p.ID] = p.View()
			}
			if err := pm.writeKnownProfiles(); err != nil {
				t.Fatalf("writeKnownProfiles: %v", err)
			}

			if tt.initial != nil {
				pm.currentUserID = tt.initial.LocalUserID
				pm.currentProfile = tt.initial.View()
				pm.prefs = tt.initial.prefs()
			}

			type stateChange struct {
				Profile  *ipn.LoginProfile
				Prefs    *ipn.Prefs
				SameNode bool
			}
			wantChanges := make([]stateChange, 0, len(tt.wantChanges))
			for _, w := range tt.wantChanges {
				wantPrefs := ipn.NewPrefs()
				w.mutPrefs(wantPrefs) // apply changes to the default prefs
				wantChanges = append(wantChanges, stateChange{
					Profile:  w.LoginProfile,
					Prefs:    wantPrefs,
					SameNode: w.sameNode,
				})
			}

			gotChanges := make([]stateChange, 0, len(tt.wantChanges))
			pm.StateChangeHook = func(profile ipn.LoginProfileView, prefs ipn.PrefsView, sameNode bool) {
				gotChanges = append(gotChanges, stateChange{
					Profile:  profile.AsStruct(),
					Prefs:    prefs.AsStruct(),
					SameNode: sameNode,
				})
			}

			tt.action(pm)

			if diff := cmp.Diff(wantChanges, gotChanges, defaultCmpOpts...); diff != "" {
				t.Errorf("StateChange callbacks: (-want +got): %v", diff)
			}
		})
	}
}
