// Tests pinned against captures for juanfont/headscale#3212.
//
// The captures were taken on 2026-04-28 against a live Tailscale SaaS
// tailnet. They reproduce the literal #3212 setup: an ACL granting
// access to autogroup:internet:* combined with autoApprovers.exitNode
// approving exit routes on tagged exit nodes. SaaS surfaces those exit
// nodes as peers in the ACL source's netmap with 0.0.0.0/0 and ::/0 in
// AllowedIPs. Headscale must do the same — that is the user-visible UX
// driving `tailscale exit-node list`.
//
// Captures live under testdata/issue_3212/ rather than testdata/
// routes_results/ so the broader TestRoutesCompat / *PeerAllowedIPs /
// *ReduceRoutes machinery does not pull them in. Those tests assume a
// PacketFilterRules wire format (CIDR prefix per dest entry) that
// differs from what SaaS emits for autogroup:internet (range form per
// IPSet range — e.g. "0.0.0.0-9.255.255.255"). Aligning that wire
// format is tracked separately; the #3212 fix is about peer
// visibility, not packet-filter encoding.

package v2

import (
	"path/filepath"
	"slices"
	"strings"
	"testing"

	"github.com/juanfont/headscale/hscontrol/types"
	"github.com/juanfont/headscale/hscontrol/types/testcapture"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"tailscale.com/net/tsaddr"
)

// TestIssue3212AutogroupInternetExitVisibility loads the b17/b18
// SaaS captures and asserts headscale's BuildPeerMap surfaces every
// exit-route advertiser to every ACL-source node — matching the peer
// list in the captured netmap.
//
// The bug fixed by this PR (#3212) was that headscale skipped
// autogroup:internet during FilterRule compilation, which silently
// dropped the matchers that Node.CanAccess reads via DestsIsTheInternet.
// The captures pin the SaaS-equivalent expectation as a regression
// guard so the same skip cannot sneak back in unnoticed.
func TestIssue3212AutogroupInternetExitVisibility(t *testing.T) {
	t.Parallel()

	files := []string{
		"routes-b17-autogroup-internet-with-exit-autoapprover",
		"routes-b18-autogroup-internet-wildcard-src-with-exit-autoapprover",
	}

	for _, testID := range files {
		path := filepath.Join(
			"testdata", "issue_3212", testID+".hujson",
		)
		tf := loadRoutesTestFile(t, path)

		t.Run(tf.TestID, func(t *testing.T) {
			t.Parallel()

			users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
			policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)

			pm, err := NewPolicyManager(
				policyJSON, users, nodes.ViewSlice(),
			)
			require.NoErrorf(t, err,
				"%s: failed to create PolicyManager", tf.TestID,
			)

			peerMap := pm.BuildPeerMap(nodes.ViewSlice())

			expected := expectedExitPeerVisibility(t, tf, nodes)
			require.NotEmptyf(t, expected,
				"%s: capture exposes no source→exit relationships — "+
					"the test is meaningless if SaaS itself never "+
					"surfaced an exit node to a source",
				tf.TestID,
			)

			for srcName, exitNames := range expected {
				srcNode := findNodeByGivenName(nodes, srcName)
				require.NotNilf(t, srcNode,
					"%s: src node %q missing from topology",
					tf.TestID, srcName,
				)

				peerIDs := make(
					map[types.NodeID]struct{},
					len(peerMap[srcNode.ID]),
				)
				for _, p := range peerMap[srcNode.ID] {
					peerIDs[p.ID()] = struct{}{}
				}

				for _, exitName := range exitNames {
					exitNode := findNodeByGivenName(nodes, exitName)
					require.NotNilf(t, exitNode,
						"%s: exit node %q missing from topology",
						tf.TestID, exitName,
					)

					_, found := peerIDs[exitNode.ID]
					assert.Truef(t, found,
						"%s: source %q must see exit node %q "+
							"as a peer via the autogroup:internet "+
							"ACL — Tailscale SaaS does (#3212)",
						tf.TestID, srcName, exitName,
					)
				}
			}
		})
	}
}

// expectedExitPeerVisibility extracts (source-node, exit-node) pairs
// the capture's netmaps witness. A node is treated as an exit-route
// advertiser when its ApprovedRoutes contain 0.0.0.0/0 or ::/0;
// a (source, exit) pair is recorded when the source's captured netmap
// lists the advertiser as a peer with 0.0.0.0/0 or ::/0 in AllowedIPs.
func expectedExitPeerVisibility(
	t *testing.T,
	tf *testcapture.Capture,
	nodes types.Nodes,
) map[string][]string {
	t.Helper()

	v4Exit := tsaddr.AllIPv4()
	v6Exit := tsaddr.AllIPv6()

	exitAdvertisers := make(map[string]bool)

	for _, n := range nodes {
		if slices.Contains(n.ApprovedRoutes, v4Exit) ||
			slices.Contains(n.ApprovedRoutes, v6Exit) {
			exitAdvertisers[n.GivenName] = true
		}
	}

	expected := make(map[string][]string)

	for srcName, capture := range tf.Captures {
		if capture.Netmap == nil {
			continue
		}

		var seen []string

		for _, peer := range capture.Netmap.Peers {
			peerName := strings.Split(peer.Name(), ".")[0]

			if !exitAdvertisers[peerName] {
				continue
			}

			peerAllowed := peer.AllowedIPs().AsSlice()
			if !slices.Contains(peerAllowed, v4Exit) &&
				!slices.Contains(peerAllowed, v6Exit) {
				continue
			}

			seen = append(seen, peerName)
		}

		if len(seen) > 0 {
			expected[srcName] = seen
		}
	}

	return expected
}
