package policy

import (
	"fmt"
	"net/netip"
	"testing"

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

func TestApproveRoutesWithPolicy_NeverRemovesRoutes(t *testing.T) {
	// Test policy that allows specific routes to be auto-approved
	aclPolicy := `
{
	"groups": {
		"group:admins": ["test@"],
	},
	"acls": [
		{"action": "accept", "src": ["*"], "dst": ["*:*"]},
	],
	"autoApprovers": {
		"routes": {
			"10.0.0.0/24": ["test@"],
			"192.168.0.0/24": ["group:admins"],
			"172.16.0.0/16": ["tag:approved"],
		},
	},
	"tagOwners": {
		"tag:approved": ["test@"],
	},
}`

	tests := []struct {
		name              string
		currentApproved   []netip.Prefix
		announcedRoutes   []netip.Prefix
		nodeHostname      string
		nodeUser          string
		nodeTags          []string
		wantApproved      []netip.Prefix
		wantChanged       bool
		wantRemovedRoutes []netip.Prefix // Routes that should NOT be in the result
	}{
		{
			name: "previously_approved_route_no_longer_advertised_remains",
			currentApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
				netip.MustParsePrefix("192.168.0.0/24"),
			},
			announcedRoutes: []netip.Prefix{
				netip.MustParsePrefix("192.168.0.0/24"), // Only this one still advertised
			},
			nodeUser: "test",
			wantApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"), // Should remain!
				netip.MustParsePrefix("192.168.0.0/24"),
			},
			wantChanged:       false,
			wantRemovedRoutes: []netip.Prefix{}, // Nothing should be removed
		},
		{
			name: "add_new_auto_approved_route_keeps_existing",
			currentApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
			},
			announcedRoutes: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),    // Still advertised
				netip.MustParsePrefix("192.168.0.0/24"), // New route
			},
			nodeUser: "test",
			wantApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
				netip.MustParsePrefix("192.168.0.0/24"), // Auto-approved via group
			},
			wantChanged: true,
		},
		{
			name: "no_announced_routes_keeps_all_approved",
			currentApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
				netip.MustParsePrefix("192.168.0.0/24"),
				netip.MustParsePrefix("172.16.0.0/16"),
			},
			announcedRoutes: []netip.Prefix{}, // No routes announced anymore
			nodeUser:        "test",
			wantApproved: []netip.Prefix{
				netip.MustParsePrefix("172.16.0.0/16"),
				netip.MustParsePrefix("10.0.0.0/24"),
				netip.MustParsePrefix("192.168.0.0/24"),
			},
			wantChanged: false,
		},
		{
			name: "manually_approved_route_not_in_policy_remains",
			currentApproved: []netip.Prefix{
				netip.MustParsePrefix("203.0.113.0/24"), // Not in auto-approvers
			},
			announcedRoutes: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"), // Can be auto-approved
			},
			nodeUser: "test",
			wantApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),    // New auto-approved
				netip.MustParsePrefix("203.0.113.0/24"), // Manual approval preserved
			},
			wantChanged: true,
		},
		{
			name: "tagged_node_gets_tag_approved_routes",
			currentApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
			},
			announcedRoutes: []netip.Prefix{
				netip.MustParsePrefix("172.16.0.0/16"), // Tag-approved route
			},
			nodeUser: "test",
			nodeTags: []string{"tag:approved"},
			wantApproved: []netip.Prefix{
				netip.MustParsePrefix("172.16.0.0/16"), // New tag-approved
				netip.MustParsePrefix("10.0.0.0/24"),   // Previous approval preserved
			},
			wantChanged: true,
		},
		{
			name: "complex_scenario_multiple_changes",
			currentApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),    // Will not be advertised
				netip.MustParsePrefix("203.0.113.0/24"), // Manual, not advertised
			},
			announcedRoutes: []netip.Prefix{
				netip.MustParsePrefix("192.168.0.0/24"),  // New, auto-approvable
				netip.MustParsePrefix("172.16.0.0/16"),   // New, not approvable (no tag)
				netip.MustParsePrefix("198.51.100.0/24"), // New, not in policy
			},
			nodeUser: "test",
			wantApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),    // Kept despite not advertised
				netip.MustParsePrefix("192.168.0.0/24"), // New auto-approved
				netip.MustParsePrefix("203.0.113.0/24"), // Kept despite not advertised
			},
			wantChanged: true,
		},
	}

	pmfs := PolicyManagerFuncsForTest([]byte(aclPolicy))

	for _, tt := range tests {
		for i, pmf := range pmfs {
			t.Run(fmt.Sprintf("%s-policy-index%d", tt.name, i), func(t *testing.T) {
				// Create test user
				user := types.User{
					Model: gorm.Model{ID: 1},
					Name:  tt.nodeUser,
				}
				users := []types.User{user}

				// Create test node
				node := types.Node{
					ID:             1,
					MachineKey:     key.NewMachine().Public(),
					NodeKey:        key.NewNode().Public(),
					Hostname:       tt.nodeHostname,
					UserID:         ptr.To(user.ID),
					User:           ptr.To(user),
					RegisterMethod: util.RegisterMethodAuthKey,
					Hostinfo: &tailcfg.Hostinfo{
						RoutableIPs: tt.announcedRoutes,
					},
					IPv4:           ptr.To(netip.MustParseAddr("100.64.0.1")),
					ApprovedRoutes: tt.currentApproved,
					Tags:           tt.nodeTags,
				}
				nodes := types.Nodes{&node}

				// Create policy manager
				pm, err := pmf(users, nodes.ViewSlice())
				require.NoError(t, err)
				require.NotNil(t, pm)

				// Test ApproveRoutesWithPolicy
				gotApproved, gotChanged := ApproveRoutesWithPolicy(
					pm,
					node.View(),
					tt.currentApproved,
					tt.announcedRoutes,
				)

				// Check change flag
				assert.Equal(t, tt.wantChanged, gotChanged, "change flag mismatch")

				// Check approved routes match expected
				if diff := cmp.Diff(tt.wantApproved, gotApproved, util.Comparers...); diff != "" {
					t.Logf("Want: %v", tt.wantApproved)
					t.Logf("Got:  %v", gotApproved)
					t.Errorf("unexpected approved routes (-want +got):\n%s", diff)
				}

				// Verify all previously approved routes are still present
				for _, prevRoute := range tt.currentApproved {
					assert.Contains(t, gotApproved, prevRoute,
						"previously approved route %s was removed - this should NEVER happen", prevRoute)
				}

				// Verify no routes were incorrectly removed
				for _, removedRoute := range tt.wantRemovedRoutes {
					assert.NotContains(t, gotApproved, removedRoute,
						"route %s should have been removed but wasn't", removedRoute)
				}
			})
		}
	}
}

func TestApproveRoutesWithPolicy_EdgeCases(t *testing.T) {
	aclPolicy := `
{
	"acls": [
		{"action": "accept", "src": ["*"], "dst": ["*:*"]},
	],
	"autoApprovers": {
		"routes": {
			"10.0.0.0/8": ["test@"],
		},
	},
}`

	tests := []struct {
		name            string
		currentApproved []netip.Prefix
		announcedRoutes []netip.Prefix
		wantApproved    []netip.Prefix
		wantChanged     bool
	}{
		{
			name:            "nil_current_approved",
			currentApproved: nil,
			announcedRoutes: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
			},
			wantApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
			},
			wantChanged: true,
		},
		{
			name:            "empty_current_approved",
			currentApproved: []netip.Prefix{},
			announcedRoutes: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
			},
			wantApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
			},
			wantChanged: true,
		},
		{
			name: "duplicate_routes_handled",
			currentApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
				netip.MustParsePrefix("10.0.0.0/24"), // Duplicate
			},
			announcedRoutes: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
			},
			wantApproved: []netip.Prefix{
				netip.MustParsePrefix("10.0.0.0/24"),
			},
			wantChanged: true, // Duplicates are removed, so it's a change
		},
	}

	pmfs := PolicyManagerFuncsForTest([]byte(aclPolicy))

	for _, tt := range tests {
		for i, pmf := range pmfs {
			t.Run(fmt.Sprintf("%s-policy-index%d", tt.name, i), func(t *testing.T) {
				// Create test user
				user := types.User{
					Model: gorm.Model{ID: 1},
					Name:  "test",
				}
				users := []types.User{user}

				node := types.Node{
					ID:             1,
					MachineKey:     key.NewMachine().Public(),
					NodeKey:        key.NewNode().Public(),
					Hostname:       "testnode",
					UserID:         ptr.To(user.ID),
					User:           ptr.To(user),
					RegisterMethod: util.RegisterMethodAuthKey,
					Hostinfo: &tailcfg.Hostinfo{
						RoutableIPs: tt.announcedRoutes,
					},
					IPv4:           ptr.To(netip.MustParseAddr("100.64.0.1")),
					ApprovedRoutes: tt.currentApproved,
				}
				nodes := types.Nodes{&node}

				pm, err := pmf(users, nodes.ViewSlice())
				require.NoError(t, err)

				gotApproved, gotChanged := ApproveRoutesWithPolicy(
					pm,
					node.View(),
					tt.currentApproved,
					tt.announcedRoutes,
				)

				assert.Equal(t, tt.wantChanged, gotChanged)

				if diff := cmp.Diff(tt.wantApproved, gotApproved, util.Comparers...); diff != "" {
					t.Errorf("unexpected approved routes (-want +got):\n%s", diff)
				}
			})
		}
	}
}

func TestApproveRoutesWithPolicy_NilPolicyManagerCase(t *testing.T) {
	user := types.User{
		Model: gorm.Model{ID: 1},
		Name:  "test",
	}

	currentApproved := []netip.Prefix{
		netip.MustParsePrefix("10.0.0.0/24"),
	}
	announcedRoutes := []netip.Prefix{
		netip.MustParsePrefix("192.168.0.0/24"),
	}

	node := types.Node{
		ID:             1,
		MachineKey:     key.NewMachine().Public(),
		NodeKey:        key.NewNode().Public(),
		Hostname:       "testnode",
		UserID:         ptr.To(user.ID),
		User:           ptr.To(user),
		RegisterMethod: util.RegisterMethodAuthKey,
		Hostinfo: &tailcfg.Hostinfo{
			RoutableIPs: announcedRoutes,
		},
		IPv4:           ptr.To(netip.MustParseAddr("100.64.0.1")),
		ApprovedRoutes: currentApproved,
	}

	// With nil policy manager, should return current approved unchanged
	gotApproved, gotChanged := ApproveRoutesWithPolicy(nil, node.View(), currentApproved, announcedRoutes)

	assert.False(t, gotChanged)
	assert.Equal(t, currentApproved, gotApproved)
}
