package netscan

import (
	"context"
	"errors"
	"net/netip"
	"strings"
	"sync"
	"testing"
	"time"
)

func TestValidateBounds(t *testing.T) {
	ok := []Config{
		{Subnets: []string{"192.168.1.0/24"}},
		{Subnets: []string{"10.0.0.0/24", "172.16.5.0/28"}, Ports: []int{22, 80}},
		{Subnets: []string{"127.0.0.1/32"}},
		{Subnets: []string{"100.64.1.0/24"}}, // CGNAT / tailnet
	}
	for _, c := range ok {
		if err := Validate(c); err != nil {
			t.Errorf("Validate(%v) = %v, want nil", c.Subnets, err)
		}
	}

	bad := map[string]Config{
		"nothing to scan":         {},
		"public range":            {Subnets: []string{"8.8.8.0/24"}},
		"whole internet":          {Subnets: []string{"0.0.0.0/0"}},
		"a slash-8 is not a flat": {Subnets: []string{"10.0.0.0/8"}},
		"a /16 is too big":        {Subnets: []string{"192.168.0.0/16"}},
		"not a cidr":              {Subnets: []string{"192.168.1.1"}},
		"ipv6":                    {Subnets: []string{"fd00::/120"}},
		"garbage":                 {Subnets: []string{"выключи свет"}},
		"bad port":                {Subnets: []string{"192.168.1.0/24"}, Ports: []int{0}},
		"huge port":               {Subnets: []string{"192.168.1.0/24"}, Ports: []int{70000}},
		"negative rate":           {Subnets: []string{"192.168.1.0/24"}, Rate: -1},
		"rate past the ceiling":   {Subnets: []string{"192.168.1.0/24"}, Rate: MaxRate + 1},
	}
	for name, c := range bad {
		if err := Validate(c); err == nil {
			t.Errorf("Validate(%s) = nil, want an error", strings.ReplaceAll(name, "\n", " "))
		}
	}
	if !errors.Is(Validate(Config{}), ErrNoSubnets) {
		t.Error("an empty block should report ErrNoSubnets")
	}
}

// The whole safety story: a scanner probes its configured range and nothing
// else. There is no API that takes a target, so this test asserts the negative
// by watching every address the dialer was handed.
func TestScanOnlyTouchesConfiguredSubnet(t *testing.T) {
	s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: 10000})
	inside := netip.MustParsePrefix("192.168.9.0/29")

	var mu sync.Mutex
	var seen []string
	s.dial = func(_ context.Context, addr string, _ time.Duration) bool {
		mu.Lock()
		seen = append(seen, addr)
		mu.Unlock()
		return addr == "192.168.9.3:80"
	}
	s.arp = func() (map[string]string, error) { return map[string]string{}, nil }

	res, err := s.Scan(context.Background())
	if err != nil {
		t.Fatalf("Scan: %v", err)
	}
	hosts := res.Hosts
	if len(hosts) != 1 || hosts[0].Addr != "192.168.9.3" || len(hosts[0].Ports) != 1 {
		t.Fatalf("hosts = %+v", hosts)
	}
	// A /29 is 8 addresses; network (.0) and broadcast (.7) are skipped.
	if len(seen) != 6 {
		t.Errorf("probed %d addresses, want 6 (a /29 minus network and broadcast): %v", len(seen), seen)
	}
	for _, a := range seen {
		host, _, _ := strings.Cut(a, ":")
		ip, err := netip.ParseAddr(host)
		if err != nil || !inside.Contains(ip) {
			t.Errorf("probed %q, which is outside the configured subnet", a)
		}
	}
}

func TestScanHonoursMaxHosts(t *testing.T) {
	s := New(Config{Subnets: []string{"192.168.9.0/24"}, Ports: []int{80}, Rate: 10000, MaxHosts: 3})
	var mu sync.Mutex
	n := 0
	s.dial = func(_ context.Context, _ string, _ time.Duration) bool {
		mu.Lock()
		n++
		mu.Unlock()
		return false
	}
	s.arp = func() (map[string]string, error) { return nil, nil }
	if _, err := s.Scan(context.Background()); err != nil {
		t.Fatal(err)
	}
	if n != 3 {
		t.Errorf("dialed %d times, want 3 (MaxHosts)", n)
	}
}

// The rate limiter must actually gate: 6 probes at 200/s cannot finish in less
// than ~25ms. Asserted loosely, since a CI box is not a stopwatch.
func TestScanIsRateLimited(t *testing.T) {
	s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: 200})
	s.dial = func(context.Context, string, time.Duration) bool { return false }
	s.arp = func() (map[string]string, error) { return nil, nil }
	start := time.Now()
	if _, err := s.Scan(context.Background()); err != nil {
		t.Fatal(err)
	}
	if el := time.Since(start); el < 20*time.Millisecond {
		t.Errorf("6 probes at 200/s took %v: the rate limiter is not gating", el)
	}
}

func TestScanStopsOnCanceledContext(t *testing.T) {
	s := New(Config{Subnets: []string{"192.168.9.0/24"}, Ports: []int{80}, Rate: 10000})
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	s.dial = func(context.Context, string, time.Duration) bool {
		t.Error("a canceled scan still dialed")
		return false
	}
	s.arp = func() (map[string]string, error) { return nil, nil }
	if _, err := s.Scan(ctx); err != nil {
		t.Fatal(err)
	}
}

// A host with every port closed but an ARP entry is still up. A host outside
// the configured range must not be reported even if the kernel knows it —
// otherwise the ARP cache, which is populated by the network rather than by
// Maven, would widen the answer past what he configured.
func TestARPFillsMACWithinTheConfiguredRangeOnly(t *testing.T) {
	s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: 10000})
	s.dial = func(context.Context, string, time.Duration) bool { return false }
	s.arp = func() (map[string]string, error) {
		return map[string]string{
			"192.168.9.2": "aa:bb:cc:dd:ee:ff",
			"10.9.9.9":    "11:22:33:44:55:66",
		}, nil
	}
	res, err := s.Scan(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	hosts := res.Hosts
	if len(hosts) != 1 {
		t.Fatalf("hosts = %+v", hosts)
	}
	if hosts[0].Addr != "192.168.9.2" || hosts[0].MAC != "aa:bb:cc:dd:ee:ff" {
		t.Errorf("host = %+v", hosts[0])
	}
	if !hosts[0].Up() {
		t.Error("an ARP entry with no open port is still a live host")
	}
}

const arpFixture = `IP address       HW type     Flags       HW address            Mask     Device
192.168.1.1      0x1         0x2         3c:84:6a:11:22:33     *        wlp1s0
192.168.1.50     0x1         0x2         b8:27:eb:44:55:66     *        wlp1s0
192.168.1.77     0x1         0x0         00:00:00:00:00:00     *        wlp1s0
not-an-ip        0x1         0x2         de:ad:be:ef:00:01     *        wlp1s0
short line
`

func TestParseARP(t *testing.T) {
	got, err := parseARP(strings.NewReader(arpFixture))
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 2 {
		t.Fatalf("got %d entries, want 2: %v", len(got), got)
	}
	if got["192.168.1.1"] != "3c:84:6a:11:22:33" || got["192.168.1.50"] != "b8:27:eb:44:55:66" {
		t.Errorf("entries = %v", got)
	}
	if _, ok := got["192.168.1.77"]; ok {
		t.Error("an incomplete ARP entry (flags 0x0) is not a discovered host")
	}
}

func TestNewAppliesDefaults(t *testing.T) {
	s := New(Config{Subnets: []string{"192.168.1.0/24"}})
	if len(s.cfg.Ports) != len(DefaultPorts) || s.cfg.Rate != DefaultRate ||
		s.cfg.MaxHosts != DefaultMaxHosts || s.cfg.Timeout != DefaultTimeout {
		t.Errorf("defaults not applied: %+v", s.cfg)
	}
	// The defaults must not alias the package slice, or a second scanner could
	// rewrite DefaultPorts through it.
	s.cfg.Ports[0] = 9999
	if DefaultPorts[0] == 9999 {
		t.Error("New aliased DefaultPorts")
	}
}

// A dense subnet must not wedge the scan. The results channel used to be sized
// by the number of HOSTS while a worker sends once per open PORT, so a range
// where the open ports outnumber the addresses filled the buffer, blocked a
// worker inside wg.Wait, and hung Scan forever. Nothing drains the channel
// before wg.Wait returns and the sends carry no ctx.Done case, so the caller's
// deadline did not rescue it either.
//
// Six addresses, eight ports, everything open: 48 sends against a buffer that
// used to hold 6. Against the old code this test does not fail, it hangs, so
// the scan runs on its own goroutine with a deadline around it.
func TestScanDoesNotWedgeWhenPortsOutnumberHosts(t *testing.T) {
	ports := []int{22, 80, 443, 8080, 8443, 9000, 9100, 9200}
	s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: ports, Rate: MaxRate})
	s.dial = func(context.Context, string, time.Duration) bool { return true }
	s.arp = func() (map[string]string, error) { return nil, nil }

	done := make(chan Result, 1)
	go func() {
		res, err := s.Scan(context.Background())
		if err != nil {
			t.Error(err)
		}
		done <- res
	}()
	select {
	case res := <-done:
		if len(res.Hosts) != 6 {
			t.Fatalf("hosts = %d, want 6: %+v", len(res.Hosts), res.Hosts)
		}
		for _, h := range res.Hosts {
			if len(h.Ports) != len(ports) {
				t.Errorf("%s reported %d open ports, want %d", h.Addr, len(h.Ports), len(ports))
			}
		}
	case <-time.After(10 * time.Second):
		t.Fatal("Scan did not return: the results channel is sized by hosts, not by probes")
	}
}

// MaxHosts is spent evenly across the configured subnets. Taking it in order
// meant a second configured LAN got whatever the first left over, which for a
// pair of /24s under the default cap was two addresses out of 254.
func TestTargetsSpreadAcrossSubnets(t *testing.T) {
	s := New(Config{Subnets: []string{"192.168.1.0/24", "192.168.2.0/24"}, MaxHosts: 20})
	targets, truncated := s.targets()
	if !truncated {
		t.Error("508 addresses under a cap of 20 is a truncated target list")
	}
	if len(targets) != 20 {
		t.Fatalf("targets = %d, want 20", len(targets))
	}
	var first, second int
	for _, a := range targets {
		switch {
		case netip.MustParsePrefix("192.168.1.0/24").Contains(a):
			first++
		case netip.MustParsePrefix("192.168.2.0/24").Contains(a):
			second++
		}
	}
	if first != 10 || second != 10 {
		t.Errorf("split %d/%d across the two subnets, want 10/10", first, second)
	}
}

// A run cut short by the caller's deadline reports itself as truncated, so the
// spoken answer can stop claiming to describe the whole network.
func TestScanReportsTruncation(t *testing.T) {
	s := New(Config{Subnets: []string{"192.168.9.0/24"}, Ports: []int{80}, Rate: 200})
	s.dial = func(context.Context, string, time.Duration) bool { return false }
	s.arp = func() (map[string]string, error) { return nil, nil }
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()
	res, err := s.Scan(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if !res.Truncated {
		t.Error("a scan stopped by the deadline must report Truncated")
	}

	full := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: MaxRate})
	full.dial = func(context.Context, string, time.Duration) bool { return false }
	full.arp = func() (map[string]string, error) { return nil, nil }
	res, err = full.Scan(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if res.Truncated {
		t.Error("a scan that covered every configured address is not truncated")
	}
}
