package v2

import (
	"encoding/json"
	"net/netip"
	"slices"
	"strings"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"github.com/juanfont/headscale/hscontrol/types"
	"github.com/prometheus/common/model"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gorm.io/gorm"
	"tailscale.com/tailcfg"
	"tailscale.com/types/ptr"
)

// aliasWithPorts creates an AliasWithPorts structure from an alias and ports.
func aliasWithPorts(alias Alias, ports ...tailcfg.PortRange) AliasWithPorts {
	return AliasWithPorts{
		Alias: alias,
		Ports: ports,
	}
}

func TestParsing(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "testuser"},
	}
	tests := []struct {
		name    string
		format  string
		acl     string
		want    []tailcfg.FilterRule
		wantErr bool
	}{
		{
			name:   "invalid-hujson",
			format: "hujson",
			acl: `
{
		`,
			want:    []tailcfg.FilterRule{},
			wantErr: true,
		},
		// The new parser will ignore all that is irrelevant
		// 		{
		// 			name:   "valid-hujson-invalid-content",
		// 			format: "hujson",
		// 			acl: `
		// {
		//   "valid_json": true,
		//   "but_a_policy_though": false
		// }
		// 				`,
		// 			want:    []tailcfg.FilterRule{},
		// 			wantErr: true,
		// 		},
		// 		{
		// 			name:   "invalid-cidr",
		// 			format: "hujson",
		// 			acl: `
		// {"example-host-1": "100.100.100.100/42"}
		// 				`,
		// 			want:    []tailcfg.FilterRule{},
		// 			wantErr: true,
		// 		},
		{
			name:   "basic-rule",
			format: "hujson",
			acl: `
{
	"hosts": {
		"host-1": "100.100.100.100",
		"subnet-1": "100.100.101.100/24",
	},

	"acls": [
		{
			"action": "accept",
			"src": [
				"subnet-1",
				"192.168.1.0/24"
			],
			"dst": [
				"*:22,3389",
				"host-1:*",
			],
		},
	],
}
		`,
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.100.101.0/24", "192.168.1.0/24"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "*", Ports: tailcfg.PortRange{First: 22, Last: 22}},
						{IP: "*", Ports: tailcfg.PortRange{First: 3389, Last: 3389}},
						{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
			},
			wantErr: false,
		},
		{
			name:   "parse-protocol",
			format: "hujson",
			acl: `
{
	"hosts": {
		"host-1": "100.100.100.100",
		"subnet-1": "100.100.101.100/24",
	},

	"acls": [
		{
			"Action": "accept",
			"src": [
				"*",
			],
			"proto": "tcp",
			"dst": [
				"host-1:*",
			],
		},
		{
			"Action": "accept",
			"src": [
				"*",
			],
			"proto": "udp",
			"dst": [
				"host-1:53",
			],
		},
		{
			"Action": "accept",
			"src": [
				"*",
			],
			"proto": "icmp",
			"dst": [
				"host-1:*",
			],
		},
	],
}`,
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
					},
					IPProto: []int{ProtocolTCP},
				},
				{
					SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.100.100.100/32", Ports: tailcfg.PortRange{First: 53, Last: 53}},
					},
					IPProto: []int{ProtocolUDP},
				},
				{
					SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
					},
					// proto:icmp only includes ICMP (1), not ICMPv6 (58)
					IPProto: []int{ProtocolICMP},
				},
			},
			wantErr: false,
		},
		{
			name:   "port-wildcard",
			format: "hujson",
			acl: `
{
	"hosts": {
		"host-1": "100.100.100.100",
		"subnet-1": "100.100.101.100/24",
	},

	"acls": [
		{
			"Action": "accept",
			"src": [
				"*",
			],
			"dst": [
				"host-1:*",
			],
		},
	],
}
`,
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
			},
			wantErr: false,
		},
		{
			name:   "port-range",
			format: "hujson",
			acl: `
{
	"hosts": {
		"host-1": "100.100.100.100",
		"subnet-1": "100.100.101.100/24",
	},

	"acls": [
		{
			"action": "accept",
			"src": [
				"subnet-1",
			],
			"dst": [
				"host-1:5400-5500",
			],
		},
	],
}
`,
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.100.101.0/24"},
					DstPorts: []tailcfg.NetPortRange{
						{
							IP:    "100.100.100.100/32",
							Ports: tailcfg.PortRange{First: 5400, Last: 5500},
						},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
			},
			wantErr: false,
		},
		{
			name:   "port-group",
			format: "hujson",
			acl: `
{
	"groups": {
		"group:example": [
			"testuser@",
		],
	},

	"hosts": {
		"host-1": "100.100.100.100",
		"subnet-1": "100.100.101.100/24",
	},

	"acls": [
		{
			"action": "accept",
			"src": [
				"group:example",
			],
			"dst": [
				"host-1:*",
			],
		},
	],
}
`,
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"200.200.200.200/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
			},
			wantErr: false,
		},
		{
			name:   "port-user",
			format: "hujson",
			acl: `
{
	"hosts": {
		"host-1": "100.100.100.100",
		"subnet-1": "100.100.101.100/24",
	},

	"acls": [
		{
			"action": "accept",
			"src": [
				"testuser@",
			],
			"dst": [
				"host-1:*",
			],
		},
	],
}
`,
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"200.200.200.200/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
			},
			wantErr: false,
		},
		{
			name:   "ipv6",
			format: "hujson",
			acl: `
{
	"hosts": {
		"host-1": "100.100.100.100/32",
		"subnet-1": "100.100.101.100/24",
	},

	"acls": [
		{
			"action": "accept",
			"src": [
				"*",
			],
			"dst": [
				"host-1:*",
			],
		},
	],
}
`,
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
			},
			wantErr: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			pol, err := unmarshalPolicy([]byte(tt.acl))
			if tt.wantErr && err == nil {
				t.Errorf("parsing() error = %v, wantErr %v", err, tt.wantErr)

				return
			} else if !tt.wantErr && err != nil {
				t.Errorf("parsing() error = %v, wantErr %v", err, tt.wantErr)

				return
			}

			if err != nil {
				return
			}

			rules, err := pol.compileFilterRules(
				users,
				types.Nodes{
					&types.Node{
						IPv4: ap("100.100.100.100"),
					},
					&types.Node{
						IPv4:     ap("200.200.200.200"),
						User:     &users[0],
						Hostinfo: &tailcfg.Hostinfo{},
					},
				}.ViewSlice())

			if (err != nil) != tt.wantErr {
				t.Errorf("parsing() error = %v, wantErr %v", err, tt.wantErr)

				return
			}

			if diff := cmp.Diff(tt.want, rules); diff != "" {
				t.Errorf("parsing() unexpected result (-want +got):\n%s", diff)
			}
		})
	}
}

func TestCompileSSHPolicy_UserMapping(t *testing.T) {
	users := types.Users{
		{Name: "user1", Model: gorm.Model{ID: 1}},
		{Name: "user2", Model: gorm.Model{ID: 2}},
	}

	// Create test nodes - use tagged nodes as SSH destinations
	// and untagged nodes as SSH sources (since group->username destinations
	// are not allowed per Tailscale security model, but groups can SSH to tags)
	nodeTaggedServer := types.Node{
		Hostname: "tagged-server",
		IPv4:     createAddr("100.64.0.1"),
		UserID:   ptr.To(users[0].ID),
		User:     ptr.To(users[0]),
		Tags:     []string{"tag:server"},
	}
	nodeTaggedDB := types.Node{
		Hostname: "tagged-db",
		IPv4:     createAddr("100.64.0.2"),
		UserID:   ptr.To(users[1].ID),
		User:     ptr.To(users[1]),
		Tags:     []string{"tag:database"},
	}
	// Add untagged node for user2 - this will be the SSH source
	// (group:admins contains user2, so user2's untagged node provides the source IPs)
	nodeUser2Untagged := types.Node{
		Hostname: "user2-device",
		IPv4:     createAddr("100.64.0.3"),
		UserID:   ptr.To(users[1].ID),
		User:     ptr.To(users[1]),
	}

	nodes := types.Nodes{&nodeTaggedServer, &nodeTaggedDB, &nodeUser2Untagged}

	tests := []struct {
		name         string
		targetNode   types.Node
		policy       *Policy
		wantSSHUsers map[string]string
		wantEmpty    bool
	}{
		{
			name:       "specific user mapping",
			targetNode: nodeTaggedServer,
			policy: &Policy{
				TagOwners: TagOwners{
					Tag("tag:server"): Owners{up("user1@")},
				},
				Groups: Groups{
					Group("group:admins"): []Username{Username("user2@")},
				},
				SSHs: []SSH{
					{
						Action:       "accept",
						Sources:      SSHSrcAliases{gp("group:admins")},
						Destinations: SSHDstAliases{tp("tag:server")},
						Users:        []SSHUser{"ssh-it-user"},
					},
				},
			},
			wantSSHUsers: map[string]string{
				"ssh-it-user": "ssh-it-user",
			},
		},
		{
			name:       "multiple specific users",
			targetNode: nodeTaggedServer,
			policy: &Policy{
				TagOwners: TagOwners{
					Tag("tag:server"): Owners{up("user1@")},
				},
				Groups: Groups{
					Group("group:admins"): []Username{Username("user2@")},
				},
				SSHs: []SSH{
					{
						Action:       "accept",
						Sources:      SSHSrcAliases{gp("group:admins")},
						Destinations: SSHDstAliases{tp("tag:server")},
						Users:        []SSHUser{"ubuntu", "admin", "deploy"},
					},
				},
			},
			wantSSHUsers: map[string]string{
				"ubuntu": "ubuntu",
				"admin":  "admin",
				"deploy": "deploy",
			},
		},
		{
			name:       "autogroup:nonroot only",
			targetNode: nodeTaggedServer,
			policy: &Policy{
				TagOwners: TagOwners{
					Tag("tag:server"): Owners{up("user1@")},
				},
				Groups: Groups{
					Group("group:admins"): []Username{Username("user2@")},
				},
				SSHs: []SSH{
					{
						Action:       "accept",
						Sources:      SSHSrcAliases{gp("group:admins")},
						Destinations: SSHDstAliases{tp("tag:server")},
						Users:        []SSHUser{SSHUser(AutoGroupNonRoot)},
					},
				},
			},
			wantSSHUsers: map[string]string{
				"*":    "=",
				"root": "",
			},
		},
		{
			name:       "root only",
			targetNode: nodeTaggedServer,
			policy: &Policy{
				TagOwners: TagOwners{
					Tag("tag:server"): Owners{up("user1@")},
				},
				Groups: Groups{
					Group("group:admins"): []Username{Username("user2@")},
				},
				SSHs: []SSH{
					{
						Action:       "accept",
						Sources:      SSHSrcAliases{gp("group:admins")},
						Destinations: SSHDstAliases{tp("tag:server")},
						Users:        []SSHUser{"root"},
					},
				},
			},
			wantSSHUsers: map[string]string{
				"root": "root",
			},
		},
		{
			name:       "autogroup:nonroot plus root",
			targetNode: nodeTaggedServer,
			policy: &Policy{
				TagOwners: TagOwners{
					Tag("tag:server"): Owners{up("user1@")},
				},
				Groups: Groups{
					Group("group:admins"): []Username{Username("user2@")},
				},
				SSHs: []SSH{
					{
						Action:       "accept",
						Sources:      SSHSrcAliases{gp("group:admins")},
						Destinations: SSHDstAliases{tp("tag:server")},
						Users:        []SSHUser{SSHUser(AutoGroupNonRoot), "root"},
					},
				},
			},
			wantSSHUsers: map[string]string{
				"*":    "=",
				"root": "root",
			},
		},
		{
			name:       "mixed specific users and autogroups",
			targetNode: nodeTaggedServer,
			policy: &Policy{
				TagOwners: TagOwners{
					Tag("tag:server"): Owners{up("user1@")},
				},
				Groups: Groups{
					Group("group:admins"): []Username{Username("user2@")},
				},
				SSHs: []SSH{
					{
						Action:       "accept",
						Sources:      SSHSrcAliases{gp("group:admins")},
						Destinations: SSHDstAliases{tp("tag:server")},
						Users:        []SSHUser{SSHUser(AutoGroupNonRoot), "root", "ubuntu", "admin"},
					},
				},
			},
			wantSSHUsers: map[string]string{
				"*":      "=",
				"root":   "root",
				"ubuntu": "ubuntu",
				"admin":  "admin",
			},
		},
		{
			name:       "no matching destination",
			targetNode: nodeTaggedDB, // Target tag:database, but policy only allows tag:server
			policy: &Policy{
				TagOwners: TagOwners{
					Tag("tag:server"):   Owners{up("user1@")},
					Tag("tag:database"): Owners{up("user1@")},
				},
				Groups: Groups{
					Group("group:admins"): []Username{Username("user2@")},
				},
				SSHs: []SSH{
					{
						Action:       "accept",
						Sources:      SSHSrcAliases{gp("group:admins")},
						Destinations: SSHDstAliases{tp("tag:server")}, // Only tag:server, not tag:database
						Users:        []SSHUser{"ssh-it-user"},
					},
				},
			},
			wantEmpty: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Validate the policy
			err := tt.policy.validate()
			require.NoError(t, err)

			// Compile SSH policy
			sshPolicy, err := tt.policy.compileSSHPolicy(users, tt.targetNode.View(), nodes.ViewSlice())
			require.NoError(t, err)

			if tt.wantEmpty {
				if sshPolicy == nil {
					return // Expected empty result
				}

				assert.Empty(t, sshPolicy.Rules, "SSH policy should be empty when no rules match")

				return
			}

			require.NotNil(t, sshPolicy)
			require.Len(t, sshPolicy.Rules, 1, "Should have exactly one SSH rule")

			rule := sshPolicy.Rules[0]
			assert.Equal(t, tt.wantSSHUsers, rule.SSHUsers, "SSH users mapping should match expected")

			// Verify principals are set correctly (should contain user2's untagged device IP since that's the source)
			require.Len(t, rule.Principals, 1)
			assert.Equal(t, "100.64.0.3", rule.Principals[0].NodeIP)

			// Verify action is set correctly
			assert.True(t, rule.Action.Accept)
			assert.True(t, rule.Action.AllowAgentForwarding)
			assert.True(t, rule.Action.AllowLocalPortForwarding)
			assert.True(t, rule.Action.AllowRemotePortForwarding)
		})
	}
}

func TestCompileSSHPolicy_CheckAction(t *testing.T) {
	users := types.Users{
		{Name: "user1", Model: gorm.Model{ID: 1}},
		{Name: "user2", Model: gorm.Model{ID: 2}},
	}

	// Use tagged nodes for SSH user mapping tests
	nodeTaggedServer := types.Node{
		Hostname: "tagged-server",
		IPv4:     createAddr("100.64.0.1"),
		UserID:   ptr.To(users[0].ID),
		User:     ptr.To(users[0]),
		Tags:     []string{"tag:server"},
	}
	nodeUser2 := types.Node{
		Hostname: "user2-device",
		IPv4:     createAddr("100.64.0.2"),
		UserID:   ptr.To(users[1].ID),
		User:     ptr.To(users[1]),
	}

	nodes := types.Nodes{&nodeTaggedServer, &nodeUser2}

	policy := &Policy{
		TagOwners: TagOwners{
			Tag("tag:server"): Owners{up("user1@")},
		},
		Groups: Groups{
			Group("group:admins"): []Username{Username("user2@")},
		},
		SSHs: []SSH{
			{
				Action:       "check",
				CheckPeriod:  model.Duration(24 * time.Hour),
				Sources:      SSHSrcAliases{gp("group:admins")},
				Destinations: SSHDstAliases{tp("tag:server")},
				Users:        []SSHUser{"ssh-it-user"},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	sshPolicy, err := policy.compileSSHPolicy(users, nodeTaggedServer.View(), nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicy)
	require.Len(t, sshPolicy.Rules, 1)

	rule := sshPolicy.Rules[0]

	// Verify SSH users are correctly mapped
	expectedUsers := map[string]string{
		"ssh-it-user": "ssh-it-user",
	}
	assert.Equal(t, expectedUsers, rule.SSHUsers)

	// Verify check action with session duration
	assert.True(t, rule.Action.Accept)
	assert.Equal(t, 24*time.Hour, rule.Action.SessionDuration)
}

// TestSSHIntegrationReproduction reproduces the exact scenario from the integration test
// TestSSHOneUserToAll that was failing with empty sshUsers.
func TestSSHIntegrationReproduction(t *testing.T) {
	// Create users matching the integration test
	users := types.Users{
		{Name: "user1", Model: gorm.Model{ID: 1}},
		{Name: "user2", Model: gorm.Model{ID: 2}},
	}

	// Create simple nodes for testing
	node1 := &types.Node{
		Hostname: "user1-node",
		IPv4:     createAddr("100.64.0.1"),
		UserID:   ptr.To(users[0].ID),
		User:     ptr.To(users[0]),
	}

	node2 := &types.Node{
		Hostname: "user2-node",
		IPv4:     createAddr("100.64.0.2"),
		UserID:   ptr.To(users[1].ID),
		User:     ptr.To(users[1]),
	}

	nodes := types.Nodes{node1, node2}

	// Create a simple policy that reproduces the issue
	// Updated to use autogroup:self instead of username destination (per Tailscale security model)
	policy := &Policy{
		Groups: Groups{
			Group("group:integration-test"): []Username{Username("user1@"), Username("user2@")},
		},
		SSHs: []SSH{
			{
				Action:       "accept",
				Sources:      SSHSrcAliases{gp("group:integration-test")},
				Destinations: SSHDstAliases{agp("autogroup:self")}, // Users can SSH to their own devices
				Users:        []SSHUser{SSHUser("ssh-it-user")},    // This is the key - specific user
			},
		},
	}

	// Validate policy
	err := policy.validate()
	require.NoError(t, err)

	// Test SSH policy compilation for node2 (owned by user2, who is in the group)
	sshPolicy, err := policy.compileSSHPolicy(users, node2.View(), nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicy)
	require.Len(t, sshPolicy.Rules, 1)

	rule := sshPolicy.Rules[0]

	// This was the failing assertion in integration test - sshUsers was empty
	assert.NotEmpty(t, rule.SSHUsers, "SSH users should not be empty")
	assert.Contains(t, rule.SSHUsers, "ssh-it-user", "ssh-it-user should be present in SSH users")
	assert.Equal(t, "ssh-it-user", rule.SSHUsers["ssh-it-user"], "ssh-it-user should map to itself")

	// Verify that ssh-it-user is correctly mapped
	expectedUsers := map[string]string{
		"ssh-it-user": "ssh-it-user",
	}
	assert.Equal(t, expectedUsers, rule.SSHUsers, "ssh-it-user should be mapped to itself")
}

// TestSSHJSONSerialization verifies that the SSH policy can be properly serialized
// to JSON and that the sshUsers field is not empty.
func TestSSHJSONSerialization(t *testing.T) {
	users := types.Users{
		{Name: "user1", Model: gorm.Model{ID: 1}},
	}

	uid := uint(1)
	node := &types.Node{
		Hostname: "test-node",
		IPv4:     createAddr("100.64.0.1"),
		UserID:   &uid,
		User:     &users[0],
	}

	nodes := types.Nodes{node}

	policy := &Policy{
		SSHs: []SSH{
			{
				Action:       "accept",
				Sources:      SSHSrcAliases{up("user1@")},
				Destinations: SSHDstAliases{up("user1@")},
				Users:        []SSHUser{"ssh-it-user", "ubuntu", "admin"},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	sshPolicy, err := policy.compileSSHPolicy(users, node.View(), nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicy)

	// Serialize to JSON to verify structure
	jsonData, err := json.MarshalIndent(sshPolicy, "", "  ")
	require.NoError(t, err)

	// Parse back to verify structure
	var parsed tailcfg.SSHPolicy

	err = json.Unmarshal(jsonData, &parsed)
	require.NoError(t, err)

	// Verify the parsed structure has the expected SSH users
	require.Len(t, parsed.Rules, 1)
	rule := parsed.Rules[0]

	expectedUsers := map[string]string{
		"ssh-it-user": "ssh-it-user",
		"ubuntu":      "ubuntu",
		"admin":       "admin",
	}
	assert.Equal(t, expectedUsers, rule.SSHUsers, "SSH users should survive JSON round-trip")

	// Verify JSON contains the SSH users (not empty)
	assert.Contains(t, string(jsonData), `"ssh-it-user"`)
	assert.Contains(t, string(jsonData), `"ubuntu"`)
	assert.Contains(t, string(jsonData), `"admin"`)
	assert.NotContains(t, string(jsonData), `"sshUsers": {}`, "SSH users should not be empty")
	assert.NotContains(t, string(jsonData), `"sshUsers": null`, "SSH users should not be null")
}

func TestCompileFilterRulesForNodeWithAutogroupSelf(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
		{Model: gorm.Model{ID: 2}, Name: "user2"},
	}

	nodes := types.Nodes{
		{
			User: ptr.To(users[0]),
			IPv4: ap("100.64.0.1"),
		},
		{
			User: ptr.To(users[0]),
			IPv4: ap("100.64.0.2"),
		},
		{
			User: ptr.To(users[1]),
			IPv4: ap("100.64.0.3"),
		},
		{
			User: ptr.To(users[1]),
			IPv4: ap("100.64.0.4"),
		},
		// Tagged device for user1
		{
			User: &users[0],
			IPv4: ap("100.64.0.5"),
			Tags: []string{"tag:test"},
		},
		// Tagged device for user2
		{
			User: &users[1],
			IPv4: ap("100.64.0.6"),
			Tags: []string{"tag:test"},
		},
	}

	// Test: Tailscale intended usage pattern (autogroup:member + autogroup:self)
	policy2 := &Policy{
		ACLs: []ACL{
			{
				Action:  "accept",
				Sources: []Alias{agp("autogroup:member")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(agp("autogroup:self"), tailcfg.PortRangeAny),
				},
			},
		},
	}

	err := policy2.validate()
	if err != nil {
		t.Fatalf("policy validation failed: %v", err)
	}

	// Test compilation for user1's first node
	node1 := nodes[0].View()

	rules, err := policy2.compileFilterRulesForNode(users, node1, nodes.ViewSlice())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if len(rules) != 1 {
		t.Fatalf("expected 1 rule, got %d", len(rules))
	}

	// Check that the rule includes:
	// - Sources: only user1's untagged devices (filtered by autogroup:self semantics)
	// - Destinations: only user1's untagged devices (autogroup:self)
	rule := rules[0]

	// Sources should ONLY include user1's untagged devices (100.64.0.1, 100.64.0.2)
	expectedSourceIPs := []string{"100.64.0.1", "100.64.0.2"}

	for _, expectedIP := range expectedSourceIPs {
		found := false

		addr := netip.MustParseAddr(expectedIP)

		for _, prefix := range rule.SrcIPs {
			pref := netip.MustParsePrefix(prefix)
			if pref.Contains(addr) {
				found = true
				break
			}
		}

		if !found {
			t.Errorf("expected source IP %s to be covered by generated prefixes %v", expectedIP, rule.SrcIPs)
		}
	}

	// Verify that other users' devices and tagged devices are not included in sources
	excludedSourceIPs := []string{"100.64.0.3", "100.64.0.4", "100.64.0.5", "100.64.0.6"}
	for _, excludedIP := range excludedSourceIPs {
		addr := netip.MustParseAddr(excludedIP)

		for _, prefix := range rule.SrcIPs {
			pref := netip.MustParsePrefix(prefix)
			if pref.Contains(addr) {
				t.Errorf("SECURITY VIOLATION: source IP %s should not be included but found in prefix %s", excludedIP, prefix)
			}
		}
	}

	expectedDestIPs := []string{"100.64.0.1/32", "100.64.0.2/32"}

	actualDestIPs := make([]string, 0, len(rule.DstPorts))
	for _, dst := range rule.DstPorts {
		actualDestIPs = append(actualDestIPs, dst.IP)
	}

	for _, expectedIP := range expectedDestIPs {
		found := slices.Contains(actualDestIPs, expectedIP)

		if !found {
			t.Errorf("expected destination IP %s to be included, got: %v", expectedIP, actualDestIPs)
		}
	}

	// Verify that other users' devices and tagged devices are not in destinations
	excludedDestIPs := []string{"100.64.0.3/32", "100.64.0.4/32", "100.64.0.5/32", "100.64.0.6/32"}
	for _, excludedIP := range excludedDestIPs {
		for _, actualIP := range actualDestIPs {
			if actualIP == excludedIP {
				t.Errorf("SECURITY: destination IP %s should not be included but found in destinations", excludedIP)
			}
		}
	}
}

// TestTagUserMutualExclusivity tests that user-owned nodes and tagged nodes
// are treated as separate identity classes and cannot inadvertently access each other.
func TestTagUserMutualExclusivity(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
		{Model: gorm.Model{ID: 2}, Name: "user2"},
	}

	nodes := types.Nodes{
		// User-owned nodes
		{
			User: ptr.To(users[0]),
			IPv4: ap("100.64.0.1"),
		},
		{
			User: ptr.To(users[1]),
			IPv4: ap("100.64.0.2"),
		},
		// Tagged nodes
		{
			User: &users[0], // "created by" tracking
			IPv4: ap("100.64.0.10"),
			Tags: []string{"tag:server"},
		},
		{
			User: &users[1], // "created by" tracking
			IPv4: ap("100.64.0.11"),
			Tags: []string{"tag:database"},
		},
	}

	policy := &Policy{
		TagOwners: TagOwners{
			Tag("tag:server"):   Owners{ptr.To(Username("user1@"))},
			Tag("tag:database"): Owners{ptr.To(Username("user2@"))},
		},
		ACLs: []ACL{
			// Rule 1: user1 (user-owned) should NOT be able to reach tagged nodes
			{
				Action:  "accept",
				Sources: []Alias{up("user1@")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(tp("tag:server"), tailcfg.PortRangeAny),
				},
			},
			// Rule 2: tag:server should be able to reach tag:database
			{
				Action:  "accept",
				Sources: []Alias{tp("tag:server")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(tp("tag:database"), tailcfg.PortRangeAny),
				},
			},
		},
	}

	err := policy.validate()
	if err != nil {
		t.Fatalf("policy validation failed: %v", err)
	}

	// Test user1's user-owned node (100.64.0.1)
	userNode := nodes[0].View()

	userRules, err := policy.compileFilterRulesForNode(users, userNode, nodes.ViewSlice())
	if err != nil {
		t.Fatalf("unexpected error for user node: %v", err)
	}

	// User1's user-owned node should NOT reach tag:server (100.64.0.10)
	// because user1@ as a source only matches user1's user-owned devices, NOT tagged devices
	for _, rule := range userRules {
		for _, dst := range rule.DstPorts {
			if dst.IP == "100.64.0.10" {
				t.Errorf("SECURITY: user-owned node should NOT reach tagged node (got dest %s in rule)", dst.IP)
			}
		}
	}

	// Test tag:server node (100.64.0.10)
	// compileFilterRulesForNode returns rules for what the node can ACCESS (as source)
	taggedNode := nodes[2].View()

	taggedRules, err := policy.compileFilterRulesForNode(users, taggedNode, nodes.ViewSlice())
	if err != nil {
		t.Fatalf("unexpected error for tagged node: %v", err)
	}

	// Tag:server (as source) should be able to reach tag:database (100.64.0.11)
	// Check destinations in the rules for this node
	foundDatabaseDest := false

	for _, rule := range taggedRules {
		// Check if this rule applies to tag:server as source
		if !slices.Contains(rule.SrcIPs, "100.64.0.10/32") {
			continue
		}

		// Check if tag:database is in destinations
		for _, dst := range rule.DstPorts {
			if dst.IP == "100.64.0.11/32" {
				foundDatabaseDest = true
				break
			}
		}

		if foundDatabaseDest {
			break
		}
	}

	if !foundDatabaseDest {
		t.Errorf("tag:server should reach tag:database but didn't find 100.64.0.11 in destinations")
	}
}

// TestAutogroupTagged tests that autogroup:tagged correctly selects all devices
// with tag-based identity (IsTagged() == true or has requested tags in tagOwners).
func TestAutogroupTagged(t *testing.T) {
	t.Parallel()

	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
		{Model: gorm.Model{ID: 2}, Name: "user2"},
	}

	nodes := types.Nodes{
		// User-owned nodes (not tagged)
		{
			User: ptr.To(users[0]),
			IPv4: ap("100.64.0.1"),
		},
		{
			User: ptr.To(users[1]),
			IPv4: ap("100.64.0.2"),
		},
		// Tagged nodes
		{
			User: &users[0], // "created by" tracking
			IPv4: ap("100.64.0.10"),
			Tags: []string{"tag:server"},
		},
		{
			User: &users[1], // "created by" tracking
			IPv4: ap("100.64.0.11"),
			Tags: []string{"tag:database"},
		},
		{
			User: &users[0],
			IPv4: ap("100.64.0.12"),
			Tags: []string{"tag:web", "tag:prod"},
		},
	}

	policy := &Policy{
		TagOwners: TagOwners{
			Tag("tag:server"):   Owners{ptr.To(Username("user1@"))},
			Tag("tag:database"): Owners{ptr.To(Username("user2@"))},
			Tag("tag:web"):      Owners{ptr.To(Username("user1@"))},
			Tag("tag:prod"):     Owners{ptr.To(Username("user1@"))},
		},
		ACLs: []ACL{
			// Rule: autogroup:tagged can reach user-owned nodes
			{
				Action:  "accept",
				Sources: []Alias{agp("autogroup:tagged")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(up("user1@"), tailcfg.PortRangeAny),
					aliasWithPorts(up("user2@"), tailcfg.PortRangeAny),
				},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	// Verify autogroup:tagged includes all tagged nodes
	ag := AutoGroupTagged
	taggedIPs, err := ag.Resolve(policy, users, nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, taggedIPs)

	// Should contain all tagged nodes
	assert.True(t, taggedIPs.Contains(*ap("100.64.0.10")), "should include tag:server")
	assert.True(t, taggedIPs.Contains(*ap("100.64.0.11")), "should include tag:database")
	assert.True(t, taggedIPs.Contains(*ap("100.64.0.12")), "should include tag:web,tag:prod")

	// Should NOT contain user-owned nodes
	assert.False(t, taggedIPs.Contains(*ap("100.64.0.1")), "should not include user1 node")
	assert.False(t, taggedIPs.Contains(*ap("100.64.0.2")), "should not include user2 node")

	// Test ACL filtering: all tagged nodes should be able to reach user nodes
	tests := []struct {
		name        string
		sourceNode  types.NodeView
		shouldReach []string // IP strings for comparison
	}{
		{
			name:        "tag:server can reach user-owned nodes",
			sourceNode:  nodes[2].View(),
			shouldReach: []string{"100.64.0.1", "100.64.0.2"},
		},
		{
			name:        "tag:database can reach user-owned nodes",
			sourceNode:  nodes[3].View(),
			shouldReach: []string{"100.64.0.1", "100.64.0.2"},
		},
		{
			name:        "tag:web,tag:prod can reach user-owned nodes",
			sourceNode:  nodes[4].View(),
			shouldReach: []string{"100.64.0.1", "100.64.0.2"},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			rules, err := policy.compileFilterRulesForNode(users, tt.sourceNode, nodes.ViewSlice())
			require.NoError(t, err)

			// Verify all expected destinations are reachable
			for _, expectedDest := range tt.shouldReach {
				found := false

				for _, rule := range rules {
					for _, dstPort := range rule.DstPorts {
						// DstPort.IP is CIDR notation like "100.64.0.1/32"
						if strings.HasPrefix(dstPort.IP, expectedDest+"/") || dstPort.IP == expectedDest {
							found = true
							break
						}
					}

					if found {
						break
					}
				}

				assert.True(t, found, "Expected to find destination %s in rules", expectedDest)
			}
		})
	}
}

func TestAutogroupSelfInSourceIsRejected(t *testing.T) {
	// Test that autogroup:self cannot be used in sources (per Tailscale spec)
	policy := &Policy{
		ACLs: []ACL{
			{
				Action:  "accept",
				Sources: []Alias{agp("autogroup:self")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(agp("autogroup:member"), tailcfg.PortRangeAny),
				},
			},
		},
	}

	err := policy.validate()
	if err == nil {
		t.Error("expected validation error when using autogroup:self in sources")
	}

	if !strings.Contains(err.Error(), "autogroup:self") {
		t.Errorf("expected error message to mention autogroup:self, got: %v", err)
	}
}

// TestAutogroupSelfWithSpecificUserSource verifies that when autogroup:self is in
// the destination and a specific user is in the source, only that user's devices
// are allowed (and only if they match the target user).
func TestAutogroupSelfWithSpecificUserSource(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
		{Model: gorm.Model{ID: 2}, Name: "user2"},
	}

	nodes := types.Nodes{
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.1")},
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.2")},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.3")},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.4")},
	}

	policy := &Policy{
		ACLs: []ACL{
			{
				Action:  "accept",
				Sources: []Alias{up("user1@")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(agp("autogroup:self"), tailcfg.PortRangeAny),
				},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	// For user1's node: sources should be user1's devices
	node1 := nodes[0].View()
	rules, err := policy.compileFilterRulesForNode(users, node1, nodes.ViewSlice())
	require.NoError(t, err)
	require.Len(t, rules, 1)

	expectedSourceIPs := []string{"100.64.0.1", "100.64.0.2"}
	for _, expectedIP := range expectedSourceIPs {
		found := false
		addr := netip.MustParseAddr(expectedIP)

		for _, prefix := range rules[0].SrcIPs {
			pref := netip.MustParsePrefix(prefix)
			if pref.Contains(addr) {
				found = true
				break
			}
		}

		assert.True(t, found, "expected source IP %s to be present", expectedIP)
	}

	actualDestIPs := make([]string, 0, len(rules[0].DstPorts))
	for _, dst := range rules[0].DstPorts {
		actualDestIPs = append(actualDestIPs, dst.IP)
	}

	expectedDestIPs := []string{"100.64.0.1/32", "100.64.0.2/32"}
	assert.ElementsMatch(t, expectedDestIPs, actualDestIPs)

	node2 := nodes[2].View()
	rules2, err := policy.compileFilterRulesForNode(users, node2, nodes.ViewSlice())
	require.NoError(t, err)
	assert.Empty(t, rules2, "user2's node should have no rules (user1@ devices can't match user2's self)")
}

// TestAutogroupSelfWithGroupSource verifies that when a group is used as source
// and autogroup:self as destination, only group members who are the same user
// as the target are allowed.
func TestAutogroupSelfWithGroupSource(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
		{Model: gorm.Model{ID: 2}, Name: "user2"},
		{Model: gorm.Model{ID: 3}, Name: "user3"},
	}

	nodes := types.Nodes{
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.1")},
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.2")},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.3")},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.4")},
		{User: ptr.To(users[2]), IPv4: ap("100.64.0.5")},
	}

	policy := &Policy{
		Groups: Groups{
			Group("group:admins"): []Username{Username("user1@"), Username("user2@")},
		},
		ACLs: []ACL{
			{
				Action:  "accept",
				Sources: []Alias{gp("group:admins")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(agp("autogroup:self"), tailcfg.PortRangeAny),
				},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	// (group:admins has user1+user2, but autogroup:self filters to same user)
	node1 := nodes[0].View()
	rules, err := policy.compileFilterRulesForNode(users, node1, nodes.ViewSlice())
	require.NoError(t, err)
	require.Len(t, rules, 1)

	expectedSrcIPs := []string{"100.64.0.1", "100.64.0.2"}
	for _, expectedIP := range expectedSrcIPs {
		found := false
		addr := netip.MustParseAddr(expectedIP)

		for _, prefix := range rules[0].SrcIPs {
			pref := netip.MustParsePrefix(prefix)
			if pref.Contains(addr) {
				found = true
				break
			}
		}

		assert.True(t, found, "expected source IP %s for user1", expectedIP)
	}

	node3 := nodes[4].View()
	rules3, err := policy.compileFilterRulesForNode(users, node3, nodes.ViewSlice())
	require.NoError(t, err)
	assert.Empty(t, rules3, "user3 should have no rules")
}

// Helper function to create IP addresses for testing.
func createAddr(ip string) *netip.Addr {
	addr, _ := netip.ParseAddr(ip)
	return &addr
}

// TestSSHWithAutogroupSelfInDestination verifies that SSH policies work correctly
// with autogroup:self in destinations.
func TestSSHWithAutogroupSelfInDestination(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
		{Model: gorm.Model{ID: 2}, Name: "user2"},
	}

	nodes := types.Nodes{
		// User1's nodes
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.1"), Hostname: "user1-node1"},
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.2"), Hostname: "user1-node2"},
		// User2's nodes
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.3"), Hostname: "user2-node1"},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.4"), Hostname: "user2-node2"},
		// Tagged node for user1 (should be excluded)
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.5"), Hostname: "user1-tagged", Tags: []string{"tag:server"}},
	}

	policy := &Policy{
		SSHs: []SSH{
			{
				Action:       "accept",
				Sources:      SSHSrcAliases{agp("autogroup:member")},
				Destinations: SSHDstAliases{agp("autogroup:self")},
				Users:        []SSHUser{"autogroup:nonroot"},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	// Test for user1's first node
	node1 := nodes[0].View()
	sshPolicy, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicy)
	require.Len(t, sshPolicy.Rules, 1)

	rule := sshPolicy.Rules[0]

	// Principals should only include user1's untagged devices
	require.Len(t, rule.Principals, 2, "should have 2 principals (user1's 2 untagged nodes)")

	principalIPs := make([]string, len(rule.Principals))
	for i, p := range rule.Principals {
		principalIPs[i] = p.NodeIP
	}

	assert.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, principalIPs)

	// Test for user2's first node
	node3 := nodes[2].View()
	sshPolicy2, err := policy.compileSSHPolicy(users, node3, nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicy2)
	require.Len(t, sshPolicy2.Rules, 1)

	rule2 := sshPolicy2.Rules[0]

	// Principals should only include user2's untagged devices
	require.Len(t, rule2.Principals, 2, "should have 2 principals (user2's 2 untagged nodes)")

	principalIPs2 := make([]string, len(rule2.Principals))
	for i, p := range rule2.Principals {
		principalIPs2[i] = p.NodeIP
	}

	assert.ElementsMatch(t, []string{"100.64.0.3", "100.64.0.4"}, principalIPs2)

	// Test for tagged node (should have no SSH rules)
	node5 := nodes[4].View()
	sshPolicy3, err := policy.compileSSHPolicy(users, node5, nodes.ViewSlice())
	require.NoError(t, err)

	if sshPolicy3 != nil {
		assert.Empty(t, sshPolicy3.Rules, "tagged nodes should not get SSH rules with autogroup:self")
	}
}

// TestSSHWithAutogroupSelfAndSpecificUser verifies that when a specific user
// is in the source and autogroup:self in destination, only that user's devices
// can SSH (and only if they match the target user).
func TestSSHWithAutogroupSelfAndSpecificUser(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
		{Model: gorm.Model{ID: 2}, Name: "user2"},
	}

	nodes := types.Nodes{
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.1")},
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.2")},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.3")},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.4")},
	}

	policy := &Policy{
		SSHs: []SSH{
			{
				Action:       "accept",
				Sources:      SSHSrcAliases{up("user1@")},
				Destinations: SSHDstAliases{agp("autogroup:self")},
				Users:        []SSHUser{"ubuntu"},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	// For user1's node: should allow SSH from user1's devices
	node1 := nodes[0].View()
	sshPolicy, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicy)
	require.Len(t, sshPolicy.Rules, 1)

	rule := sshPolicy.Rules[0]
	require.Len(t, rule.Principals, 2, "user1 should have 2 principals")

	principalIPs := make([]string, len(rule.Principals))
	for i, p := range rule.Principals {
		principalIPs[i] = p.NodeIP
	}

	assert.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, principalIPs)

	// For user2's node: should have no rules (user1's devices can't match user2's self)
	node3 := nodes[2].View()
	sshPolicy2, err := policy.compileSSHPolicy(users, node3, nodes.ViewSlice())
	require.NoError(t, err)

	if sshPolicy2 != nil {
		assert.Empty(t, sshPolicy2.Rules, "user2 should have no SSH rules since source is user1")
	}
}

// TestSSHWithAutogroupSelfAndGroup verifies SSH with group sources and autogroup:self destinations.
func TestSSHWithAutogroupSelfAndGroup(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
		{Model: gorm.Model{ID: 2}, Name: "user2"},
		{Model: gorm.Model{ID: 3}, Name: "user3"},
	}

	nodes := types.Nodes{
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.1")},
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.2")},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.3")},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.4")},
		{User: ptr.To(users[2]), IPv4: ap("100.64.0.5")},
	}

	policy := &Policy{
		Groups: Groups{
			Group("group:admins"): []Username{Username("user1@"), Username("user2@")},
		},
		SSHs: []SSH{
			{
				Action:       "accept",
				Sources:      SSHSrcAliases{gp("group:admins")},
				Destinations: SSHDstAliases{agp("autogroup:self")},
				Users:        []SSHUser{"root"},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	// For user1's node: should allow SSH from user1's devices only (not user2's)
	node1 := nodes[0].View()
	sshPolicy, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicy)
	require.Len(t, sshPolicy.Rules, 1)

	rule := sshPolicy.Rules[0]
	require.Len(t, rule.Principals, 2, "user1 should have 2 principals (only user1's nodes)")

	principalIPs := make([]string, len(rule.Principals))
	for i, p := range rule.Principals {
		principalIPs[i] = p.NodeIP
	}

	assert.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, principalIPs)

	// For user3's node: should have no rules (not in group:admins)
	node5 := nodes[4].View()
	sshPolicy2, err := policy.compileSSHPolicy(users, node5, nodes.ViewSlice())
	require.NoError(t, err)

	if sshPolicy2 != nil {
		assert.Empty(t, sshPolicy2.Rules, "user3 should have no SSH rules (not in group)")
	}
}

// TestSSHWithAutogroupSelfExcludesTaggedDevices verifies that tagged devices
// are excluded from both sources and destinations when autogroup:self is used.
func TestSSHWithAutogroupSelfExcludesTaggedDevices(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
	}

	nodes := types.Nodes{
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.1"), Hostname: "untagged1"},
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.2"), Hostname: "untagged2"},
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.3"), Hostname: "tagged1", Tags: []string{"tag:server"}},
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.4"), Hostname: "tagged2", Tags: []string{"tag:web"}},
	}

	policy := &Policy{
		TagOwners: TagOwners{
			Tag("tag:server"): Owners{up("user1@")},
			Tag("tag:web"):    Owners{up("user1@")},
		},
		SSHs: []SSH{
			{
				Action:       "accept",
				Sources:      SSHSrcAliases{agp("autogroup:member")},
				Destinations: SSHDstAliases{agp("autogroup:self")},
				Users:        []SSHUser{"admin"},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	// For untagged node: should only get principals from other untagged nodes
	node1 := nodes[0].View()
	sshPolicy, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicy)
	require.Len(t, sshPolicy.Rules, 1)

	rule := sshPolicy.Rules[0]
	require.Len(t, rule.Principals, 2, "should only have 2 principals (untagged nodes)")

	principalIPs := make([]string, len(rule.Principals))
	for i, p := range rule.Principals {
		principalIPs[i] = p.NodeIP
	}

	assert.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, principalIPs,
		"should only include untagged devices")

	// For tagged node: should get no SSH rules
	node3 := nodes[2].View()
	sshPolicy2, err := policy.compileSSHPolicy(users, node3, nodes.ViewSlice())
	require.NoError(t, err)

	if sshPolicy2 != nil {
		assert.Empty(t, sshPolicy2.Rules, "tagged node should get no SSH rules with autogroup:self")
	}
}

// TestSSHWithAutogroupSelfAndMixedDestinations tests that SSH rules can have both
// autogroup:self and other destinations (like tag:router) in the same rule, and that
// autogroup:self filtering only applies to autogroup:self destinations, not others.
func TestSSHWithAutogroupSelfAndMixedDestinations(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "user1"},
		{Model: gorm.Model{ID: 2}, Name: "user2"},
	}

	nodes := types.Nodes{
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.1"), Hostname: "user1-device"},
		{User: ptr.To(users[0]), IPv4: ap("100.64.0.2"), Hostname: "user1-device2"},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.3"), Hostname: "user2-device"},
		{User: ptr.To(users[1]), IPv4: ap("100.64.0.4"), Hostname: "user2-router", Tags: []string{"tag:router"}},
	}

	policy := &Policy{
		TagOwners: TagOwners{
			Tag("tag:router"): Owners{up("user2@")},
		},
		SSHs: []SSH{
			{
				Action:       "accept",
				Sources:      SSHSrcAliases{agp("autogroup:member")},
				Destinations: SSHDstAliases{agp("autogroup:self"), tp("tag:router")},
				Users:        []SSHUser{"admin"},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	// Test 1: Compile for user1's device (should only match autogroup:self destination)
	node1 := nodes[0].View()
	sshPolicy1, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicy1)
	require.Len(t, sshPolicy1.Rules, 1, "user1's device should have 1 SSH rule (autogroup:self)")

	// Verify autogroup:self rule has filtered sources (only same-user devices)
	selfRule := sshPolicy1.Rules[0]
	require.Len(t, selfRule.Principals, 2, "autogroup:self rule should only have user1's devices")

	selfPrincipals := make([]string, len(selfRule.Principals))
	for i, p := range selfRule.Principals {
		selfPrincipals[i] = p.NodeIP
	}

	require.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, selfPrincipals,
		"autogroup:self rule should only include same-user untagged devices")

	// Test 2: Compile for router (should only match tag:router destination)
	routerNode := nodes[3].View() // user2-router
	sshPolicyRouter, err := policy.compileSSHPolicy(users, routerNode, nodes.ViewSlice())
	require.NoError(t, err)
	require.NotNil(t, sshPolicyRouter)
	require.Len(t, sshPolicyRouter.Rules, 1, "router should have 1 SSH rule (tag:router)")

	routerRule := sshPolicyRouter.Rules[0]

	routerPrincipals := make([]string, len(routerRule.Principals))
	for i, p := range routerRule.Principals {
		routerPrincipals[i] = p.NodeIP
	}

	require.Contains(t, routerPrincipals, "100.64.0.1", "router rule should include user1's device (unfiltered sources)")
	require.Contains(t, routerPrincipals, "100.64.0.2", "router rule should include user1's other device (unfiltered sources)")
	require.Contains(t, routerPrincipals, "100.64.0.3", "router rule should include user2's device (unfiltered sources)")
}

// TestAutogroupSelfWithNonExistentUserInGroup verifies that when a group
// contains a non-existent user, partial resolution still works correctly.
// This reproduces the issue from https://github.com/juanfont/headscale/issues/2990
// where autogroup:self breaks when groups contain users that don't have
// registered nodes.
func TestAutogroupSelfWithNonExistentUserInGroup(t *testing.T) {
	users := types.Users{
		{Model: gorm.Model{ID: 1}, Name: "superadmin"},
		{Model: gorm.Model{ID: 2}, Name: "admin"},
		{Model: gorm.Model{ID: 3}, Name: "direction"},
	}

	nodes := types.Nodes{
		// superadmin's device
		{ID: 1, User: ptr.To(users[0]), IPv4: ap("100.64.0.1"), Hostname: "superadmin-device"},
		// admin's device
		{ID: 2, User: ptr.To(users[1]), IPv4: ap("100.64.0.2"), Hostname: "admin-device"},
		// direction's device
		{ID: 3, User: ptr.To(users[2]), IPv4: ap("100.64.0.3"), Hostname: "direction-device"},
		// tagged servers
		{ID: 4, IPv4: ap("100.64.0.10"), Hostname: "common-server", Tags: []string{"tag:common"}},
		{ID: 5, IPv4: ap("100.64.0.11"), Hostname: "tech-server", Tags: []string{"tag:tech"}},
		{ID: 6, IPv4: ap("100.64.0.12"), Hostname: "privileged-server", Tags: []string{"tag:privileged"}},
	}

	policy := &Policy{
		Groups: Groups{
			// group:superadmin contains "phantom_user" who doesn't exist
			Group("group:superadmin"): []Username{Username("superadmin@"), Username("phantom_user@")},
			Group("group:admin"):      []Username{Username("admin@")},
			Group("group:direction"):  []Username{Username("direction@")},
		},
		TagOwners: TagOwners{
			Tag("tag:common"):     Owners{gp("group:superadmin")},
			Tag("tag:tech"):       Owners{gp("group:superadmin")},
			Tag("tag:privileged"): Owners{gp("group:superadmin")},
		},
		ACLs: []ACL{
			{
				// Rule 1: all groups -> tag:common
				Action:  "accept",
				Sources: []Alias{gp("group:superadmin"), gp("group:admin"), gp("group:direction")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(tp("tag:common"), tailcfg.PortRangeAny),
				},
			},
			{
				// Rule 2: superadmin + admin -> tag:tech
				Action:  "accept",
				Sources: []Alias{gp("group:superadmin"), gp("group:admin")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(tp("tag:tech"), tailcfg.PortRangeAny),
				},
			},
			{
				// Rule 3: superadmin -> tag:privileged + autogroup:self
				Action:  "accept",
				Sources: []Alias{gp("group:superadmin")},
				Destinations: []AliasWithPorts{
					aliasWithPorts(tp("tag:privileged"), tailcfg.PortRangeAny),
					aliasWithPorts(agp("autogroup:self"), tailcfg.PortRangeAny),
				},
			},
		},
	}

	err := policy.validate()
	require.NoError(t, err)

	containsIP := func(rules []tailcfg.FilterRule, ip string) bool {
		addr := netip.MustParseAddr(ip)

		for _, rule := range rules {
			for _, dp := range rule.DstPorts {
				// DstPort IPs may be bare addresses or CIDR prefixes
				pref, err := netip.ParsePrefix(dp.IP)
				if err != nil {
					// Try as bare address
					a, err2 := netip.ParseAddr(dp.IP)
					if err2 != nil {
						continue
					}

					if a == addr {
						return true
					}

					continue
				}

				if pref.Contains(addr) {
					return true
				}
			}
		}

		return false
	}

	containsSrcIP := func(rules []tailcfg.FilterRule, ip string) bool {
		addr := netip.MustParseAddr(ip)

		for _, rule := range rules {
			for _, srcIP := range rule.SrcIPs {
				pref, err := netip.ParsePrefix(srcIP)
				if err != nil {
					a, err2 := netip.ParseAddr(srcIP)
					if err2 != nil {
						continue
					}

					if a == addr {
						return true
					}

					continue
				}

				if pref.Contains(addr) {
					return true
				}
			}
		}

		return false
	}

	// Test superadmin's device: should have rules with tag:common, tag:tech, tag:privileged destinations
	// and superadmin's IP should appear in sources (partial resolution of group:superadmin works)
	superadminNode := nodes[0].View()
	superadminRules, err := policy.compileFilterRulesForNode(users, superadminNode, nodes.ViewSlice())
	require.NoError(t, err)
	assert.True(t, containsIP(superadminRules, "100.64.0.10"), "rules should include tag:common server")
	assert.True(t, containsIP(superadminRules, "100.64.0.11"), "rules should include tag:tech server")
	assert.True(t, containsIP(superadminRules, "100.64.0.12"), "rules should include tag:privileged server")

	// Key assertion: superadmin's IP should appear as a source in rules
	// despite phantom_user in group:superadmin causing a partial resolution error
	assert.True(t, containsSrcIP(superadminRules, "100.64.0.1"),
		"superadmin's IP should appear in sources despite phantom_user in group:superadmin")

	// Test admin's device: admin is in group:admin which has NO phantom users.
	// The key bug was: when group:superadmin (with phantom_user) appeared as a source
	// alongside group:admin, the error from resolving group:superadmin caused its
	// partial result to be discarded via `continue`. With the fix, superadmin's IPs
	// from group:superadmin are retained alongside admin's IPs from group:admin.
	adminNode := nodes[1].View()
	adminRules, err := policy.compileFilterRulesForNode(users, adminNode, nodes.ViewSlice())
	require.NoError(t, err)

	// Rule 1 sources: [group:superadmin, group:admin, group:direction]
	// Without fix: group:superadmin discarded -> only admin + direction IPs in sources
	// With fix: superadmin IP preserved -> superadmin + admin + direction IPs in sources
	assert.True(t, containsIP(adminRules, "100.64.0.10"),
		"admin rules should include tag:common server (group:admin resolves correctly)")
	assert.True(t, containsSrcIP(adminRules, "100.64.0.1"),
		"superadmin's IP should be in sources for rules seen by admin (partial resolution preserved)")
	assert.True(t, containsSrcIP(adminRules, "100.64.0.2"),
		"admin's own IP should be in sources")

	// Test direction's device: similar to admin, verifies group:direction sources work
	directionNode := nodes[2].View()
	directionRules, err := policy.compileFilterRulesForNode(users, directionNode, nodes.ViewSlice())
	require.NoError(t, err)
	assert.True(t, containsIP(directionRules, "100.64.0.10"),
		"direction rules should include tag:common server")
	assert.True(t, containsSrcIP(directionRules, "100.64.0.3"),
		"direction's own IP should be in sources")
	// With fix: superadmin's IP preserved in rules that include group:superadmin
	assert.True(t, containsSrcIP(directionRules, "100.64.0.1"),
		"superadmin's IP should be in sources for rule 1 (partial resolution preserved)")
}

func TestMergeFilterRules(t *testing.T) {
	tests := []struct {
		name  string
		input []tailcfg.FilterRule
		want  []tailcfg.FilterRule
	}{
		{
			name:  "empty input",
			input: []tailcfg.FilterRule{},
			want:  []tailcfg.FilterRule{},
		},
		{
			name: "single rule unchanged",
			input: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
			},
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
			},
		},
		{
			name: "merge two rules with same key",
			input: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP},
				},
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP},
				},
			},
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP},
				},
			},
		},
		{
			name: "different SrcIPs not merged",
			input: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.3/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
				{
					SrcIPs: []string{"100.64.0.2/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.3/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
			},
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.3/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
				{
					SrcIPs: []string{"100.64.0.2/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.3/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
			},
		},
		{
			name: "different IPProto not merged",
			input: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 53, Last: 53}},
					},
					IPProto: []int{ProtocolUDP},
				},
			},
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 53, Last: 53}},
					},
					IPProto: []int{ProtocolUDP},
				},
			},
		},
		{
			name: "DstPorts combined without deduplication",
			input: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
			},
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
						{IP: "100.64.0.2/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP},
				},
			},
		},
		{
			name: "merge three rules with same key",
			input: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.3/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
				{
					SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.3/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
				{
					SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.4/32", Ports: tailcfg.PortRange{First: 443, Last: 443}},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
			},
			want: []tailcfg.FilterRule{
				{
					SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32"},
					DstPorts: []tailcfg.NetPortRange{
						{IP: "100.64.0.3/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
						{IP: "100.64.0.3/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
						{IP: "100.64.0.4/32", Ports: tailcfg.PortRange{First: 443, Last: 443}},
					},
					IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
				},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := mergeFilterRules(tt.input)
			if diff := cmp.Diff(tt.want, got); diff != "" {
				t.Errorf("mergeFilterRules() mismatch (-want +got):\n%s", diff)
			}
		})
	}
}
