package servertest_test

import (
	"context"
	"net/http"
	"testing"
	"time"

	"github.com/juanfont/headscale/hscontrol/servertest"
	"github.com/juanfont/headscale/hscontrol/types"
	"github.com/juanfont/headscale/hscontrol/types/change"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"tailscale.com/tailcfg"
)

// TestPingNode verifies the full ping round-trip: the server sends a
// [tailcfg.PingRequest] via [tailcfg.MapResponse], the real [controlclient.Direct] handles it
// by making a HEAD request back over Noise, and the ping tracker records
// the latency.
func TestPingNode(t *testing.T) {
	t.Parallel()

	h := servertest.NewHarness(t, 1)

	nm := h.Client(0).Netmap()
	require.NotNil(t, nm)
	require.True(t, nm.SelfNode.Valid())

	nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec

	st := h.Server.State()
	pingID, responseCh := st.RegisterPing(nodeID)

	defer st.CancelPing(pingID)

	callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID
	h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
		URL: callbackURL,
		Log: true,
	}))

	select {
	case latency := <-responseCh:
		assert.Positive(t, latency, "latency should be positive, got %v", latency)
		assert.Less(t, latency, 10*time.Second, "latency should be reasonable, got %v", latency)
	case <-time.After(15 * time.Second):
		t.Fatal("ping response not received within 15s")
	}
}

// TestPingDisconnectedNode verifies that pinging a disconnected node
// results in no response (the channel never receives).
func TestPingDisconnectedNode(t *testing.T) {
	t.Parallel()

	h := servertest.NewHarness(t, 1)

	nm := h.Client(0).Netmap()
	require.NotNil(t, nm)

	nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec

	// Disconnect the client.
	h.Client(0).Disconnect(t)

	st := h.Server.State()
	pingID, responseCh := st.RegisterPing(nodeID)

	defer st.CancelPing(pingID)

	callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID
	h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
		URL: callbackURL,
		Log: true,
	}))

	select {
	case <-responseCh:
		t.Fatal("should not receive response from disconnected node")
	case <-time.After(3 * time.Second):
		// Expected: no response.
	}
}

// TestPingTwoSameNode verifies that two concurrent pings to the same
// node complete independently.
func TestPingTwoSameNode(t *testing.T) {
	t.Parallel()

	h := servertest.NewHarness(t, 1)

	nm := h.Client(0).Netmap()
	require.NotNil(t, nm)

	nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec

	st := h.Server.State()

	pingID1, ch1 := st.RegisterPing(nodeID)
	defer st.CancelPing(pingID1)

	pingID2, ch2 := st.RegisterPing(nodeID)
	defer st.CancelPing(pingID2)

	require.NotEqual(t, pingID1, pingID2)

	// Send both [tailcfg.PingRequest]s.
	url1 := h.Server.URL + "/machine/ping-response?id=" + pingID1
	url2 := h.Server.URL + "/machine/ping-response?id=" + pingID2

	h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
		URL: url1,
	}))
	h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
		URL: url2,
	}))

	timeout := time.After(15 * time.Second)

	var got1, got2 bool

	for !got1 || !got2 {
		select {
		case latency := <-ch1:
			assert.GreaterOrEqual(t, latency, time.Duration(0))

			got1 = true
		case latency := <-ch2:
			assert.GreaterOrEqual(t, latency, time.Duration(0))

			got2 = true
		case <-timeout:
			t.Fatalf("timed out: got1=%v got2=%v", got1, got2)
		}
	}
}

// TestPingResolveByHostname verifies that [state.State.ResolveNode] can find a node
// by hostname and that the resolved node can be pinged.
func TestPingResolveByHostname(t *testing.T) {
	t.Parallel()

	h := servertest.NewHarness(t, 1, servertest.WithDefaultClientOptions(
		servertest.WithHostname("my-test-host"),
	))

	st := h.Server.State()

	// Resolve by hostname.
	node, ok := st.ResolveNode("my-test-host")
	require.True(t, ok, "should resolve node by hostname")

	nodeID := node.ID()

	pingID, responseCh := st.RegisterPing(nodeID)
	defer st.CancelPing(pingID)

	callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID
	h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
		URL: callbackURL,
		Log: true,
	}))

	select {
	case latency := <-responseCh:
		assert.Positive(t, latency)
	case <-time.After(15 * time.Second):
		t.Fatal("ping response not received")
	}
}

// TestPingResponseHandlerRejectsNonHEAD verifies the endpoint returns 405
// for method verbs other than HEAD, even when chi is configured to allow
// them.
func TestPingResponseHandlerRejectsNonHEAD(t *testing.T) {
	t.Parallel()

	h := servertest.NewHarness(t, 1)

	for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodPut} {
		t.Run(method, func(t *testing.T) {
			t.Parallel()

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

			req, err := http.NewRequestWithContext(ctx, method, h.Server.URL+"/machine/ping-response?id=x", nil)
			require.NoError(t, err)

			resp, err := http.DefaultClient.Do(req)
			if err != nil {
				// chi may refuse the method entirely; that's equivalent to 405.
				return
			}
			defer resp.Body.Close()

			assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
		})
	}
}
