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

package ping

import (
	"context"
	"errors"
	"fmt"
	"net"
	"testing"
	"time"

	"golang.org/x/net/icmp"
	"golang.org/x/net/ipv4"
	"golang.org/x/net/ipv6"
	"tailscale.com/tstest"
	"tailscale.com/util/mak"
)

var (
	localhost = &net.IPAddr{IP: net.IPv4(127, 0, 0, 1)}
)

func TestPinger(t *testing.T) {
	clock := &tstest.Clock{}

	ctx := context.Background()
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	p, closeP := mockPinger(t, clock)
	defer closeP()

	bodyData := []byte("data goes here")

	// Start a ping in the background
	r := make(chan time.Duration, 1)
	go func() {
		dur, err := p.Send(ctx, localhost, bodyData)
		if err != nil {
			t.Errorf("p.Send: %v", err)
			r <- 0
		} else {
			r <- dur
		}
	}()

	p.waitOutstanding(t, ctx, 1)

	// Fake a response from ourself
	fakeResponse := mustMarshal(t, &icmp.Message{
		Type: ipv4.ICMPTypeEchoReply,
		Code: ipv4.ICMPTypeEchoReply.Protocol(),
		Body: &icmp.Echo{
			ID:   1234,
			Seq:  1,
			Data: bodyData,
		},
	})

	const fakeDuration = 100 * time.Millisecond
	p.handleResponse(fakeResponse, clock.Now().Add(fakeDuration), v4Type)

	select {
	case dur := <-r:
		want := fakeDuration
		if dur != want {
			t.Errorf("wanted ping response time = %d; got %d", want, dur)
		}
	case <-ctx.Done():
		t.Fatal("did not get response by timeout")
	}
}

func TestV6Pinger(t *testing.T) {
	if c, err := net.ListenPacket("udp6", "::1"); err != nil {
		// skip test if we can't use IPv6.
		t.Skipf("IPv6 not supported: %s", err)
	} else {
		c.Close()
	}

	clock := &tstest.Clock{}

	ctx := context.Background()
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	p, closeP := mockPinger(t, clock)
	defer closeP()

	bodyData := []byte("data goes here")

	// Start a ping in the background
	r := make(chan time.Duration, 1)
	go func() {
		dur, err := p.Send(ctx, &net.IPAddr{IP: net.ParseIP("::")}, bodyData)
		if err != nil {
			t.Errorf("p.Send: %v", err)
			r <- 0
		} else {
			r <- dur
		}
	}()

	p.waitOutstanding(t, ctx, 1)

	// Fake a response from ourself
	fakeResponse := mustMarshal(t, &icmp.Message{
		Type: ipv6.ICMPTypeEchoReply,
		Code: ipv6.ICMPTypeEchoReply.Protocol(),
		Body: &icmp.Echo{
			ID:   1234,
			Seq:  1,
			Data: bodyData,
		},
	})

	const fakeDuration = 100 * time.Millisecond
	p.handleResponse(fakeResponse, clock.Now().Add(fakeDuration), v6Type)

	select {
	case dur := <-r:
		want := fakeDuration
		if dur != want {
			t.Errorf("wanted ping response time = %d; got %d", want, dur)
		}
	case <-ctx.Done():
		t.Fatal("did not get response by timeout")
	}
}

func TestPingerTimeout(t *testing.T) {
	ctx := context.Background()
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	clock := &tstest.Clock{}
	p, closeP := mockPinger(t, clock)
	defer closeP()

	// Send a ping in the background
	r := make(chan error, 1)
	go func() {
		_, err := p.Send(ctx, localhost, []byte("data goes here"))
		r <- err
	}()

	// Wait until we're blocking
	p.waitOutstanding(t, ctx, 1)

	// Close everything down
	p.cleanupOutstanding()

	// Should have got an error from the ping
	err := <-r
	if !errors.Is(err, net.ErrClosed) {
		t.Errorf("wanted errors.Is(err, net.ErrClosed); got=%v", err)
	}
}

func TestPingerMismatch(t *testing.T) {
	clock := &tstest.Clock{}

	ctx := context.Background()
	ctx, cancel := context.WithTimeout(ctx, 1*time.Second) // intentionally short
	defer cancel()

	p, closeP := mockPinger(t, clock)
	defer closeP()

	bodyData := []byte("data goes here")

	// Start a ping in the background
	r := make(chan time.Duration, 1)
	go func() {
		dur, err := p.Send(ctx, localhost, bodyData)
		if err != nil && !errors.Is(err, context.DeadlineExceeded) {
			t.Errorf("p.Send: %v", err)
			r <- 0
		} else {
			r <- dur
		}
	}()

	p.waitOutstanding(t, ctx, 1)

	// "Receive" a bunch of intentionally malformed packets that should not
	// result in the Send call above returning
	badPackets := []struct {
		name string
		pkt  *icmp.Message
	}{
		{
			name: "wrong type",
			pkt: &icmp.Message{
				Type: ipv4.ICMPTypeDestinationUnreachable,
				Code: 0,
				Body: &icmp.DstUnreach{},
			},
		},
		{
			name: "wrong id",
			pkt: &icmp.Message{
				Type: ipv4.ICMPTypeEchoReply,
				Code: 0,
				Body: &icmp.Echo{
					ID:   9999,
					Seq:  1,
					Data: bodyData,
				},
			},
		},
		{
			name: "wrong seq",
			pkt: &icmp.Message{
				Type: ipv4.ICMPTypeEchoReply,
				Code: 0,
				Body: &icmp.Echo{
					ID:   1234,
					Seq:  5,
					Data: bodyData,
				},
			},
		},
		{
			name: "bad body",
			pkt: &icmp.Message{
				Type: ipv4.ICMPTypeEchoReply,
				Code: 0,
				Body: &icmp.Echo{
					ID:  1234,
					Seq: 1,

					// Intentionally missing first byte
					Data: bodyData[1:],
				},
			},
		},
	}

	const fakeDuration = 100 * time.Millisecond
	tm := clock.Now().Add(fakeDuration)

	for _, tt := range badPackets {
		fakeResponse := mustMarshal(t, tt.pkt)
		p.handleResponse(fakeResponse, tm, v4Type)
	}

	// Also "receive" a packet that does not unmarshal as an ICMP packet
	p.handleResponse([]byte("foo"), tm, v4Type)

	select {
	case <-r:
		t.Fatal("wanted timeout")
	case <-ctx.Done():
		t.Logf("test correctly timed out")
	}
}

// udpingPacketConn will convert potentially ICMP destination addrs to UDP
// destination addrs in WriteTo so that a test that is intending to send ICMP
// traffic will instead send UDP traffic, without the higher level Pinger being
// aware of this difference.
type udpingPacketConn struct {
	net.PacketConn
	// destPort will be configured by the test to be the peer expected to respond to a ping.
	destPort uint16
}

func (u *udpingPacketConn) WriteTo(body []byte, dest net.Addr) (int, error) {
	switch d := dest.(type) {
	case *net.IPAddr:
		udpAddr := &net.UDPAddr{
			IP:   d.IP,
			Port: int(u.destPort),
			Zone: d.Zone,
		}
		return u.PacketConn.WriteTo(body, udpAddr)
	}
	return 0, fmt.Errorf("unimplemented udpingPacketConn for %T", dest)
}

func mockPinger(t *testing.T, clock *tstest.Clock) (*Pinger, func()) {
	p := New(context.Background(), t.Logf, nil)
	p.timeNow = clock.Now
	p.Verbose = true
	p.id = 1234

	// In tests, we use UDP so that we can test without being root; this
	// doesn't matter because we mock out the ICMP reply below to be a real
	// ICMP echo reply packet.
	conn4, err := net.ListenPacket("udp4", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("net.ListenPacket: %v", err)
	}

	conn6, err := net.ListenPacket("udp6", "[::]:0")
	if err != nil {
		t.Fatalf("net.ListenPacket: %v", err)
	}

	conn4 = &udpingPacketConn{
		destPort:   12345,
		PacketConn: conn4,
	}
	conn6 = &udpingPacketConn{
		PacketConn: conn6,
		destPort:   12345,
	}

	mak.Set(&p.conns, v4Type, conn4)
	mak.Set(&p.conns, v6Type, conn6)
	done := func() {
		if err := p.Close(); err != nil {
			t.Errorf("error on close: %v", err)
		}
	}
	return p, done
}

func mustMarshal(t *testing.T, m *icmp.Message) []byte {
	t.Helper()

	b, err := m.Marshal(nil)
	if err != nil {
		t.Fatal(err)
	}
	return b
}

func (p *Pinger) waitOutstanding(t *testing.T, ctx context.Context, count int) {
	// This is a bit janky, but... we busy-loop to wait for the Send call
	// to write to our map so we know that a response will be handled.
	var haveMapEntry bool
	for !haveMapEntry {
		time.Sleep(10 * time.Millisecond)
		select {
		case <-ctx.Done():
			t.Error("no entry in ping map before timeout")
			return
		default:
		}

		p.mu.Lock()
		haveMapEntry = len(p.pings) == count
		p.mu.Unlock()
	}
}
