package prechecks

import (
	"context"
	"crypto/tls"
	"errors"
	"net"
	"testing"

	"github.com/quic-go/quic-go"
	"github.com/rs/zerolog"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/mock/gomock"

	"github.com/cloudflare/cloudflared/edgediscovery/allregions"
	"github.com/cloudflare/cloudflared/mocks"
)

// Test constants for repeated string values.
const (
	testRegion1Global = region1Global
	testRegion2Global = region2Global
	testRegion1US     = region1US
	testRegion2US     = region2US
	testRegion1Fed    = region1Fed
	testRegion2Fed    = region2Fed

	testEdgePort = 7844
)

// testTLSConfig is a minimal *tls.Config for tests. Mock dialers never
// perform a real TLS handshake, so an empty config is sufficient.
var testTLSConfig = &tls.Config{} //nolint:gosec

// mockQuicConnection is a minimal test double for quic.Connection.
type mockQuicConnection struct {
	closeErr error
}

func (m *mockQuicConnection) AcceptStream(_ context.Context) (quic.Stream, error) {
	return nil, nil
}

func (m *mockQuicConnection) AcceptUniStream(_ context.Context) (quic.ReceiveStream, error) {
	return nil, nil
}

func (m *mockQuicConnection) OpenStream() (quic.Stream, error) {
	return nil, nil
}

func (m *mockQuicConnection) OpenStreamSync(_ context.Context) (quic.Stream, error) {
	return nil, nil
}

func (m *mockQuicConnection) OpenUniStream() (quic.SendStream, error) {
	return nil, nil
}

func (m *mockQuicConnection) OpenUniStreamSync(_ context.Context) (quic.SendStream, error) {
	return nil, nil
}

func (m *mockQuicConnection) LocalAddr() net.Addr {
	return nil
}

func (m *mockQuicConnection) RemoteAddr() net.Addr {
	return nil
}

func (m *mockQuicConnection) CloseWithError(_ quic.ApplicationErrorCode, _ string) error {
	return m.closeErr
}

func (m *mockQuicConnection) Context() context.Context {
	return context.Background()
}

func (m *mockQuicConnection) ConnectionState() quic.ConnectionState {
	return quic.ConnectionState{}
}

func (m *mockQuicConnection) SendDatagram(_ []byte) error {
	return nil
}

func (m *mockQuicConnection) ReceiveDatagram(_ context.Context) ([]byte, error) {
	return nil, nil
}

func (m *mockQuicConnection) AddPath(*quic.Transport) (*quic.Path, error) {
	return nil, nil
}

// Helper to create test edge addresses.
func createTestEdgeAddr(ip string, port int, version allregions.EdgeIPVersion) *allregions.EdgeAddr {
	parsedIP := net.ParseIP(ip)
	return &allregions.EdgeAddr{
		TCP:       &net.TCPAddr{IP: parsedIP, Port: port},
		UDP:       &net.UDPAddr{IP: parsedIP, Port: port},
		IPVersion: version,
	}
}

// probeDNS tests.

func TestProbeDNS_Success(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	v4Addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
	v6Addr := createTestEdgeAddr("2001:db8::1", testEdgePort, allregions.V6)

	resolver := mocks.NewMockDNSResolver(ctrl)
	resolver.EXPECT().Resolve("").Return([][]*allregions.EdgeAddr{{v4Addr, v6Addr}}, nil)

	targets := probeDNS(resolver, "")

	require.Len(t, targets, 1)
	assert.NotEmpty(t, targets[0].Addrs)
	assert.Equal(t, ProbeTypeDNS, targets[0].DNSResult.Type)
	assert.Equal(t, testRegion1Global, targets[0].DNSResult.Target)
	assert.Equal(t, Pass, targets[0].DNSResult.ProbeStatus)
	assert.Equal(t, dnsResolvedSuccessfully, targets[0].DNSResult.Details)
}

func TestProbeDNS_MultipleRegions(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	v4Addr1 := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
	v4Addr2 := createTestEdgeAddr("192.0.2.2", testEdgePort, allregions.V4)

	resolver := mocks.NewMockDNSResolver(ctrl)
	resolver.EXPECT().Resolve("").Return([][]*allregions.EdgeAddr{{v4Addr1}, {v4Addr2}}, nil)

	targets := probeDNS(resolver, "")

	require.Len(t, targets, 2)

	assert.Equal(t, testRegion1Global, targets[0].DNSResult.Target)
	assert.Equal(t, Pass, targets[0].DNSResult.ProbeStatus)
	assert.NotEmpty(t, targets[0].Addrs)

	assert.Equal(t, testRegion2Global, targets[1].DNSResult.Target)
	assert.Equal(t, Pass, targets[1].DNSResult.ProbeStatus)
	assert.NotEmpty(t, targets[1].Addrs)
}

func TestProbeDNS_ResolverError(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	resolver := mocks.NewMockDNSResolver(ctrl)
	resolver.EXPECT().Resolve("").Return(nil, errors.New("DNS lookup failed"))

	targets := probeDNS(resolver, "")

	require.Len(t, targets, 2)
	assert.Empty(t, targets[0].Addrs)
	assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus)
	assert.Equal(t, "DNS lookup failed", targets[0].DNSResult.Details)
	assert.Contains(t, targets[0].DNSResult.Action, testRegion1Global)
	assert.Empty(t, targets[1].Addrs)
	assert.Equal(t, Fail, targets[1].DNSResult.ProbeStatus)
	assert.Contains(t, targets[1].DNSResult.Action, testRegion2Global)
}

func TestProbeDNS_EmptyResults(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	resolver := mocks.NewMockDNSResolver(ctrl)
	resolver.EXPECT().Resolve("").Return([][]*allregions.EdgeAddr{}, nil)

	targets := probeDNS(resolver, "")

	require.Len(t, targets, 2)
	assert.Empty(t, targets[0].Addrs)
	assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus)
	assert.Equal(t, dnsNoAddressesReturned, targets[0].DNSResult.Details)
}

func TestProbeDNS_EmptyGroup(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	resolver := mocks.NewMockDNSResolver(ctrl)
	resolver.EXPECT().Resolve("").Return([][]*allregions.EdgeAddr{{}}, nil)

	targets := probeDNS(resolver, "")

	require.Len(t, targets, 1)
	assert.Empty(t, targets[0].Addrs)
	assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus)
	assert.Equal(t, dnsNoAddressesReturned, targets[0].DNSResult.Details)
}

func TestProbeDNS_RegionFlag(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	v4Addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
	resolver := mocks.NewMockDNSResolver(ctrl)
	resolver.EXPECT().Resolve("us").Return([][]*allregions.EdgeAddr{{v4Addr}}, nil)

	targets := probeDNS(resolver, "us")

	require.Len(t, targets, 1)
	assert.Equal(t, testRegion1US, targets[0].DNSResult.Target)
}

// probeQUIC tests.

func TestProbeQUIC_Success(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockConn := &mockQuicConnection{}
	dialer := mocks.NewMockQUICDialer(ctrl)
	dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockConn, nil)

	addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
	logger := zerolog.New(nil)

	result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)

	assert.Equal(t, ProbeTypeQUIC, result.Type)
	assert.Equal(t, Pass, result.ProbeStatus)
	assert.Equal(t, detailsQUICHandshakeSuccessful, result.Details)
}

func TestProbeQUIC_DialError(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	dialer := mocks.NewMockQUICDialer(ctrl)
	dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("connection refused"))

	addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
	logger := zerolog.New(nil)

	result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)

	assert.Equal(t, ProbeTypeQUIC, result.Type)
	assert.Equal(t, Fail, result.ProbeStatus)
	assert.Equal(t, detailsQUICHandshakeFailed, result.Details)
	assert.Equal(t, actionQUICBlocked, result.Action)
}

func TestProbeQUIC_CloseErrorDoesNotAffectResult(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockConn := &mockQuicConnection{closeErr: errors.New("close failed")}
	dialer := mocks.NewMockQUICDialer(ctrl)
	dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockConn, nil)

	addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
	logger := zerolog.New(nil)

	result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)

	assert.Equal(t, ProbeTypeQUIC, result.Type)
	assert.Equal(t, Pass, result.ProbeStatus)
	assert.Equal(t, detailsQUICHandshakeSuccessful, result.Details)
}

func TestProbeQUIC_ContextTimeout(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	dialer := mocks.NewMockQUICDialer(ctrl)
	dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, context.DeadlineExceeded)

	addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
	logger := zerolog.New(nil)

	result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)

	assert.Equal(t, Fail, result.ProbeStatus)
	assert.Equal(t, detailsQUICHandshakeFailed, result.Details)
}

// probeHTTP2 tests.

func TestProbeHTTP2_Success(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	dialer := mocks.NewMockTCPDialer(ctrl)
	dialer.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&net.TCPConn{}, nil)

	addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)

	result := probeHTTP2(context.Background(), testTLSConfig, dialer, addr)

	assert.Equal(t, ProbeTypeHTTP2, result.Type)
	assert.Equal(t, Pass, result.ProbeStatus)
	assert.Equal(t, detailsHTTP2HandshakeSuccessful, result.Details)
}

func TestProbeHTTP2_DialError(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	dialer := mocks.NewMockTCPDialer(ctrl)
	dialer.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("connection refused"))

	addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)

	result := probeHTTP2(context.Background(), testTLSConfig, dialer, addr)

	assert.Equal(t, ProbeTypeHTTP2, result.Type)
	assert.Equal(t, Fail, result.ProbeStatus)
	assert.Equal(t, detailsHTTP2BlockedOrUnreachable, result.Details)
	assert.Equal(t, actionHTTP2Blocked, result.Action)
}

// probeManagementAPI tests.

func TestProbeManagementAPI_Success(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	dialer := mocks.NewMockManagementDialer(ctrl)
	dialer.EXPECT().DialContext(gomock.Any(), "tcp", "api.cloudflare.com:443").Return(&net.TCPConn{}, nil)

	result := probeManagementAPI(context.Background(), dialer)

	assert.Equal(t, ProbeTypeManagementAPI, result.Type)
	assert.Equal(t, "Cloudflare API", result.Component)
	assert.Equal(t, "api.cloudflare.com:443", result.Target)
	assert.Equal(t, Pass, result.ProbeStatus)
	assert.Equal(t, detailsApiReachable, result.Details)
}

func TestProbeManagementAPI_DialError(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	dialer := mocks.NewMockManagementDialer(ctrl)
	dialer.EXPECT().DialContext(gomock.Any(), "tcp", "api.cloudflare.com:443").Return(nil, errors.New("connection refused"))

	result := probeManagementAPI(context.Background(), dialer)

	assert.Equal(t, ProbeTypeManagementAPI, result.Type)
	assert.Equal(t, Fail, result.ProbeStatus)
	assert.Equal(t, detailsAPIConnectionFailed, result.Details)
	assert.Equal(t, actionAPIUnreachable, result.Action)
}

// skipResult tests.

func TestSkipResult(t *testing.T) {
	t.Parallel()

	result := skipResult(ProbeTypeQUIC, "UDP Connectivity", "Port 7844 (QUIC)", detailsDNSPrerequisiteFailed)

	assert.Equal(t, ProbeTypeQUIC, result.Type)
	assert.Equal(t, "UDP Connectivity", result.Component)
	assert.Equal(t, "Port 7844 (QUIC)", result.Target)
	assert.Equal(t, Skip, result.ProbeStatus)
	assert.Equal(t, detailsDNSPrerequisiteFailed, result.Details)
}

// regionTargets tests.

func TestRegionTargets(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name        string
		region      string
		wantRegion1 string
		wantRegion2 string
		description string
	}{
		{
			name:        "empty region returns global hostnames",
			region:      "",
			wantRegion1: testRegion1Global,
			wantRegion2: testRegion2Global,
		},
		{
			name:        "us region returns US hostnames",
			region:      "us",
			wantRegion1: testRegion1US,
			wantRegion2: testRegion2US,
		},
		{
			name:        "fed region returns fed hostnames",
			region:      "fed",
			wantRegion1: testRegion1Fed,
			wantRegion2: testRegion2Fed,
		},
		{
			name:        "unknown region defaults to global hostnames",
			region:      "eu",
			wantRegion1: testRegion1Global,
			wantRegion2: testRegion2Global,
			description: "Unknown regions should default to global hostnames",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			gotR1, gotR2 := regionTargets(tt.region)
			assert.Equal(t, tt.wantRegion1, gotR1)
			assert.Equal(t, tt.wantRegion2, gotR2)
		})
	}
}

// addrsByFamily tests.

func TestAddrsByFamily(t *testing.T) {
	t.Parallel()

	v4Addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
	v6Addr := createTestEdgeAddr("2001:db8::1", testEdgePort, allregions.V6)

	tests := []struct {
		name      string
		group     []*allregions.EdgeAddr
		ipVersion allregions.ConfigIPVersion
		wantV4    bool
		wantV6    bool
	}{
		{
			name:      "auto returns both v4 and v6",
			group:     []*allregions.EdgeAddr{v4Addr, v6Addr},
			ipVersion: allregions.Auto,
			wantV4:    true,
			wantV6:    true,
		},
		{
			name:      "ipv4 only returns v4 and nil v6",
			group:     []*allregions.EdgeAddr{v4Addr, v6Addr},
			ipVersion: allregions.IPv4Only,
			wantV4:    true,
			wantV6:    false,
		},
		{
			name:      "ipv6 only returns nil v4 and v6",
			group:     []*allregions.EdgeAddr{v4Addr, v6Addr},
			ipVersion: allregions.IPv6Only,
			wantV4:    false,
			wantV6:    true,
		},
		{
			name:      "empty group returns nil for both",
			group:     []*allregions.EdgeAddr{},
			ipVersion: allregions.Auto,
			wantV4:    false,
			wantV6:    false,
		},
		{
			name:      "only v4 available returns v4 and nil v6",
			group:     []*allregions.EdgeAddr{v4Addr},
			ipVersion: allregions.Auto,
			wantV4:    true,
			wantV6:    false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			gotV4, gotV6 := addrsByFamily(tt.group, tt.ipVersion)
			if tt.wantV4 {
				assert.NotNil(t, gotV4)
			} else {
				assert.Nil(t, gotV4)
			}
			if tt.wantV6 {
				assert.NotNil(t, gotV6)
			} else {
				assert.Nil(t, gotV6)
			}
		})
	}
}

// IPv6 address tests for probeQUIC.

func TestProbeQUIC_IPv6Address(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockConn := &mockQuicConnection{}
	dialer := mocks.NewMockQUICDialer(ctrl)
	dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockConn, nil)

	addr := createTestEdgeAddr("2001:db8::1", testEdgePort, allregions.V6)
	logger := zerolog.New(nil)

	result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)

	assert.Equal(t, Pass, result.ProbeStatus)
	assert.Equal(t, detailsQUICHandshakeSuccessful, result.Details)
}

// IPv6 address tests for probeHTTP2.

func TestProbeHTTP2_IPv6Address(t *testing.T) {
	t.Parallel()
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	dialer := mocks.NewMockTCPDialer(ctrl)
	dialer.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&net.TCPConn{}, nil)

	addr := createTestEdgeAddr("2001:db8::1", testEdgePort, allregions.V6)

	result := probeHTTP2(context.Background(), testTLSConfig, dialer, addr)

	assert.Equal(t, Pass, result.ProbeStatus)
}

// resolveStaticEdge tests.

// TestResolveStaticEdge_SingleAddr verifies that a single resolvable --edge
// addr produces one group labeled with the original addr string.
func TestResolveStaticEdge_SingleAddr(t *testing.T) {
	t.Parallel()
	logger := zerolog.Nop()
	targets := resolveStaticEdge([]string{"127.0.0.1:7844"}, &logger)
	require.Len(t, targets, 1)
	assert.Equal(t, "127.0.0.1:7844", targets[0].DNSResult.Target)
	assert.Equal(t, Pass, targets[0].DNSResult.ProbeStatus)
	assert.NotEmpty(t, targets[0].Addrs)
}

// TestResolveStaticEdge_MultipleAddrs verifies that multiple --edge addrs each
// produce their own ResolvedTarget, preserving per-addr structure and label order.
func TestResolveStaticEdge_MultipleAddrs(t *testing.T) {
	t.Parallel()
	logger := zerolog.Nop()
	targets := resolveStaticEdge([]string{"127.0.0.1:7844", "127.0.0.2:7844"}, &logger)
	require.Len(t, targets, 2)
	assert.Equal(t, "127.0.0.1:7844", targets[0].DNSResult.Target)
	assert.Equal(t, "127.0.0.2:7844", targets[1].DNSResult.Target)
}

// TestResolveStaticEdge_InvalidAddr verifies that an unresolvable addr is
// silently skipped and does not appear in the output.
func TestResolveStaticEdge_InvalidAddr(t *testing.T) {
	t.Parallel()
	logger := zerolog.Nop()
	// "not-a-valid-addr" has no port — ResolveTCPAddr will fail.
	targets := resolveStaticEdge([]string{"not-a-valid-addr"}, &logger)
	require.Len(t, targets, 1)
	assert.Equal(t, "not-a-valid-addr", targets[0].DNSResult.Target)
	assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus)
	assert.Equal(t, dnsNoAddressesReturned, targets[0].DNSResult.Details)
	assert.Empty(t, targets[0].Addrs)
}

// TestResolveStaticEdge_PartiallyValid verifies that a mix of valid and invalid
// addrs produces one ResolvedTarget per addr — valid ones with Addrs and a Skip
// DNSResult, invalid ones with nil Addrs and a Fail DNSResult.
func TestResolveStaticEdge_PartiallyValid(t *testing.T) {
	t.Parallel()
	logger := zerolog.Nop()
	targets := resolveStaticEdge([]string{"127.0.0.1:7844", "not-a-valid-addr", "127.0.0.2:7844"}, &logger)
	require.Len(t, targets, 3)
	assert.Equal(t, "127.0.0.1:7844", targets[0].DNSResult.Target)
	assert.Equal(t, Pass, targets[0].DNSResult.ProbeStatus)
	assert.NotEmpty(t, targets[0].Addrs)
	assert.Equal(t, "not-a-valid-addr", targets[1].DNSResult.Target)
	assert.Equal(t, Fail, targets[1].DNSResult.ProbeStatus)
	assert.Empty(t, targets[1].Addrs)
	assert.Equal(t, "127.0.0.2:7844", targets[2].DNSResult.Target)
	assert.Equal(t, Pass, targets[2].DNSResult.ProbeStatus)
	assert.NotEmpty(t, targets[2].Addrs)
}
