// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package catalog

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

	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

	"github.com/hashicorp/go-hclog"

	"github.com/hashicorp/consul/agent/consul/state"
	"github.com/hashicorp/consul/agent/consul/stream"
	"github.com/hashicorp/consul/agent/grpc-external/limiter"
	"github.com/hashicorp/consul/agent/local"
	"github.com/hashicorp/consul/agent/proxycfg"
	"github.com/hashicorp/consul/agent/structs"
	"github.com/hashicorp/consul/agent/token"
)

func TestConfigSource_Success(t *testing.T) {
	serviceID := structs.NewServiceID("web-sidecar-proxy-1", nil)
	nodeName := "node-name"
	token := "token"

	store := testStateStore(t)

	// Register the proxy in the catalog/state store at port 9999.
	require.NoError(t, store.EnsureRegistration(0, &structs.RegisterRequest{
		Node: nodeName,
		Service: &structs.NodeService{
			ID:      serviceID.ID,
			Service: "web-sidecar-proxy",
			Port:    9999,
			Kind:    structs.ServiceKindConnectProxy,
			Proxy: structs.ConnectProxyConfig{
				Config: map[string]any{
					"local_connect_timeout_ms": 123,
				},
			},
		},
	}))

	// testConfigManager builds a ConfigManager that emits a ConfigSnapshot whenever
	// Register is called, and closes the watch channel when Deregister is called.
	//
	// Though a little odd, this allows us to make assertions on the sync goroutine's
	// behavior without sleeping which leads to slow/racy tests.
	cfgMgr := testConfigManager(t, serviceID, nodeName, token)

	lim := NewMockSessionLimiter(t)

	session1 := newMockSession(t)
	session1TermCh := make(limiter.SessionTerminatedChan)
	session1.On("Terminated").Return(session1TermCh)
	session1.On("End").Return()

	session2 := newMockSession(t)
	session2TermCh := make(limiter.SessionTerminatedChan)
	session2.On("Terminated").Return(session2TermCh)
	session2.On("End").Return()

	lim.On("BeginSession").Return(session1, nil).Once()
	lim.On("BeginSession").Return(session2, nil).Once()

	mgr := NewConfigSource(Config{
		Manager:        cfgMgr,
		LocalState:     testLocalState(t),
		Logger:         hclog.NewNullLogger(),
		GetStore:       func() Store { return store },
		SessionLimiter: lim,
	})
	t.Cleanup(mgr.Shutdown)

	snapCh, termCh, _, cancelWatch1, err := mgr.Watch(serviceID, nodeName, token)
	require.NoError(t, err)
	require.Equal(t, session1TermCh, termCh)

	// Expect Register to have been called with the proxy's inital port.
	select {
	case snap := <-snapCh:
		require.Equal(t, 9999, snap.Port)
		require.Equal(t, token, snap.ProxyID.Token)
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout waiting for snapshot")
	}

	// Update the proxy's port to 8888.
	require.NoError(t, store.EnsureRegistration(0, &structs.RegisterRequest{
		Node: nodeName,
		Service: &structs.NodeService{
			ID:      serviceID.ID,
			Service: "web-sidecar-proxy",
			Port:    8888,
			Kind:    structs.ServiceKindConnectProxy,
			Proxy: structs.ConnectProxyConfig{
				Config: map[string]any{
					"local_connect_timeout_ms": 123,
				},
			},
		},
	}))

	// Expect Register to have been called again with the proxy's new port.
	select {
	case snap := <-snapCh:
		require.Equal(t, 8888, snap.Port)
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout waiting for snapshot")
	}

	// Update proxy-defaults.
	require.NoError(t, store.EnsureConfigEntry(1, &structs.ProxyConfigEntry{
		Name: structs.ProxyConfigGlobal,
		Config: map[string]any{
			"max_inbound_connections": 321,
		},
	}))

	// Expect Register to have been called again with the new merged config.
	select {
	case snap := <-snapCh:
		require.Equal(t, map[string]any{
			"local_connect_timeout_ms": 123,
			"max_inbound_connections":  321,
		}, snap.Proxy.Config)
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout waiting for snapshot")
	}

	// Start another watch.
	_, termCh2, _, cancelWatch2, err := mgr.Watch(serviceID, nodeName, token)
	require.NoError(t, err)
	require.Equal(t, session2TermCh, termCh2)

	// Expect the service to have not been re-registered by the second watch.
	select {
	case <-snapCh:
		t.Fatal("service shouldn't have been re-registered")
	case <-time.After(100 * time.Millisecond):
	}

	// Expect cancelling the first watch to *not* de-register the service.
	cancelWatch1()
	select {
	case <-snapCh:
		t.Fatal("service shouldn't have been de-registered until other watch went away")
	case <-time.After(100 * time.Millisecond):
	}

	// Expect cancelling the other watch to de-register the service.
	cancelWatch2()
	select {
	case _, ok := <-snapCh:
		require.False(t, ok, "channel should've been closed")
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout waiting for service to be de-registered")
	}

	session1.AssertCalled(t, "End")
	session2.AssertCalled(t, "End")
}

func TestConfigSource_LocallyManagedService(t *testing.T) {
	serviceID := structs.NewServiceID("web-sidecar-proxy-1", nil)
	proxyID := serviceID
	nodeName := "node-1"
	token := "token"

	localState := testLocalState(t)
	localState.AddServiceWithChecks(&structs.NodeService{ID: serviceID.ID}, nil, "", false)

	localWatcher := NewMockWatcher(t)
	localWatcher.On("Watch", proxyID, nodeName, token).
		Return(make(<-chan *proxycfg.ConfigSnapshot), nil, nil, context.CancelFunc(func() {}), nil)

	mgr := NewConfigSource(Config{
		NodeName:          nodeName,
		LocalState:        localState,
		LocalConfigSource: localWatcher,
		Logger:            hclog.NewNullLogger(),
		GetStore:          func() Store { panic("state store shouldn't have been used") },
		SessionLimiter:    nullSessionLimiter{},
	})
	t.Cleanup(mgr.Shutdown)

	_, _, _, _, err := mgr.Watch(proxyID, nodeName, token)
	require.NoError(t, err)
}

func TestConfigSource_ErrorRegisteringService(t *testing.T) {
	serviceID := structs.NewServiceID("web-sidecar-proxy-1", nil)
	nodeName := "node-name"
	token := "token"

	store := testStateStore(t)

	require.NoError(t, store.EnsureRegistration(0, &structs.RegisterRequest{
		Node: nodeName,
		Service: &structs.NodeService{
			ID:      serviceID.ID,
			Service: "web-sidecar-proxy",
			Port:    9999,
			Kind:    structs.ServiceKindConnectProxy,
		},
	}))

	var canceledWatch bool
	cancel := context.CancelFunc(func() { canceledWatch = true })

	cfgMgr := NewMockConfigManager(t)

	cfgMgr.On("Watch", mock.Anything).
		Return(make(<-chan *proxycfg.ConfigSnapshot), cancel)

	cfgMgr.On("Register", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
		Return(errors.New("KABOOM"))

	session := newMockSession(t)
	session.On("End").Return()

	lim := NewMockSessionLimiter(t)
	lim.On("BeginSession").Return(session, nil)

	mgr := NewConfigSource(Config{
		Manager:        cfgMgr,
		LocalState:     testLocalState(t),
		Logger:         hclog.NewNullLogger(),
		GetStore:       func() Store { return store },
		SessionLimiter: lim,
	})
	t.Cleanup(mgr.Shutdown)

	_, _, _, _, err := mgr.Watch(serviceID, nodeName, token)
	require.Error(t, err)
	require.True(t, canceledWatch, "watch should've been canceled")

	session.AssertCalled(t, "End")
}

func TestConfigSource_ErrorInSyncLoop(t *testing.T) {
	serviceID := structs.NewServiceID("web-sidecar-proxy-1", nil)
	nodeName := "node-name"
	token := "token"

	store := testStateStore(t)

	// Register the proxy in the catalog/state store at port 9999.
	require.NoError(t, store.EnsureRegistration(0, &structs.RegisterRequest{
		Node: nodeName,
		Service: &structs.NodeService{
			ID:      serviceID.ID,
			Service: "web-sidecar-proxy",
			Port:    9999,
			Kind:    structs.ServiceKindConnectProxy,
			Proxy: structs.ConnectProxyConfig{
				Config: map[string]any{
					"local_connect_timeout_ms": 123,
				},
			},
		},
	}))

	cfgMgr := NewMockConfigManager(t)
	{
		proxyID := proxycfg.ProxyID{
			ServiceID: serviceID,
			NodeName:  nodeName,
			Token:     token,
		}
		snapCh := make(chan *proxycfg.ConfigSnapshot, 1)
		cfgMgr.On("Watch", proxyID).
			Return((<-chan *proxycfg.ConfigSnapshot)(snapCh), context.CancelFunc(func() {}), nil)

		// Answer the register call successfully for session 1 starting (Repeatability = 1).
		// Session 2 should not have caused a re-register to happen.
		cfgMgr.On("Register", mock.Anything, mock.Anything, source, token, false).
			Run(func(args mock.Arguments) {
				id := args.Get(0).(proxycfg.ProxyID)
				ns := args.Get(1).(*structs.NodeService)

				snapCh <- &proxycfg.ConfigSnapshot{
					ProxyID: id,
					Port:    ns.Port,
					Proxy:   ns.Proxy,
				}
			}).
			Return(nil).
			Repeatability = 1

		// Error on subsequent registrations afterwards (during the sync loop).
		cfgMgr.On("Register", mock.Anything, mock.Anything, source, token, false).
			Return(fmt.Errorf("intentional registration error"))

		cfgMgr.On("Deregister", proxyID, source).
			Run(func(mock.Arguments) { close(snapCh) }).
			Return()
	}

	lim := NewMockSessionLimiter(t)
	session1TermCh := make(limiter.SessionTerminatedChan)
	session2TermCh := make(limiter.SessionTerminatedChan)
	{
		session1 := newMockSession(t)
		session1.On("Terminated").Return(session1TermCh)
		session1.On("End").Return()

		session2 := newMockSession(t)
		session2.On("Terminated").Return(session2TermCh)
		session2.On("End").Return()

		lim.On("BeginSession").Return(session1, nil).Once()
		lim.On("BeginSession").Return(session2, nil).Once()
	}

	mgr := NewConfigSource(Config{
		Manager:        cfgMgr,
		LocalState:     testLocalState(t),
		Logger:         hclog.NewNullLogger(),
		GetStore:       func() Store { return store },
		SessionLimiter: lim,
	})
	t.Cleanup(mgr.Shutdown)

	snapCh, termCh, cfgSrcTerminated1, cancelWatch1, err := mgr.Watch(serviceID, nodeName, token)
	require.NoError(t, err)
	require.Equal(t, session1TermCh, termCh)

	// Expect Register to have been called with the proxy's inital port.
	select {
	case snap := <-snapCh:
		require.Equal(t, 9999, snap.Port)
		require.Equal(t, token, snap.ProxyID.Token)
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout waiting for snapshot")
	}

	// Start another watch.
	_, termCh2, cfgSrcTerminated2, cancelWatch2, err := mgr.Watch(serviceID, nodeName, token)
	require.NoError(t, err)
	require.Equal(t, session2TermCh, termCh2)

	// Expect the service to have not been re-registered by the second watch.
	select {
	case <-snapCh:
		t.Fatal("service shouldn't have been re-registered")
	case <-time.After(100 * time.Millisecond):
	}

	// Ensure that no config-source syncLoops were terminated.
	select {
	case <-cfgSrcTerminated1:
		t.Fatal("unexpected config-source termination 1")
	case <-cfgSrcTerminated2:
		t.Fatal("unexpected config-source termination 2")
	default:
	}

	// Update the proxy's port to 8888.
	// This should trigger the config-source syncLoop termination channel due to an error.
	require.NoError(t, store.EnsureRegistration(0, &structs.RegisterRequest{
		Node: nodeName,
		Service: &structs.NodeService{
			ID:      serviceID.ID,
			Service: "web-sidecar-proxy",
			Port:    8888,
			Kind:    structs.ServiceKindConnectProxy,
			Proxy: structs.ConnectProxyConfig{
				Config: map[string]any{
					"local_connect_timeout_ms": 123,
				},
			},
		},
	}))

	// Expect both config sources to have terminated when the syncLoop errors.
	select {
	case _, ok := <-cfgSrcTerminated1:
		cancelWatch1()
		require.False(t, ok)
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout waiting for config-source termination 1")
	}
	select {
	case _, ok := <-cfgSrcTerminated2:
		cancelWatch2()
		require.False(t, ok)
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout waiting for config-source termination 2")
	}

	// Expect the snap channels to have been closed.
	select {
	case _, ok := <-snapCh:
		require.False(t, ok)
	case <-time.After(100 * time.Millisecond):
		t.Fatal("snap channel was not closed")
	}
}

func TestConfigSource_NotProxyService(t *testing.T) {
	serviceID := structs.NewServiceID("web", nil)
	nodeName := "node-name"
	token := "token"

	store := testStateStore(t)

	require.NoError(t, store.EnsureRegistration(0, &structs.RegisterRequest{
		Node: nodeName,
		Service: &structs.NodeService{
			ID:      serviceID.ID,
			Service: "web",
			Port:    9999,
			Kind:    structs.ServiceKindTypical,
		},
	}))

	var canceledWatch bool
	cancel := context.CancelFunc(func() { canceledWatch = true })

	cfgMgr := NewMockConfigManager(t)

	cfgMgr.On("Watch", mock.Anything).
		Return(make(<-chan *proxycfg.ConfigSnapshot), cancel)

	mgr := NewConfigSource(Config{
		Manager:        cfgMgr,
		LocalState:     testLocalState(t),
		Logger:         hclog.NewNullLogger(),
		GetStore:       func() Store { return store },
		SessionLimiter: nullSessionLimiter{},
	})
	t.Cleanup(mgr.Shutdown)

	_, _, _, _, err := mgr.Watch(serviceID, nodeName, token)
	require.Error(t, err)
	require.Contains(t, err.Error(), "must be a sidecar proxy or gateway")
	require.True(t, canceledWatch, "watch should've been canceled")
}

func TestConfigSource_SessionLimiterError(t *testing.T) {
	lim := NewMockSessionLimiter(t)
	lim.On("BeginSession").Return(nil, limiter.ErrCapacityReached)

	src := NewConfigSource(Config{
		LocalState:     testLocalState(t),
		SessionLimiter: lim,
	})
	t.Cleanup(src.Shutdown)

	_, _, _, _, err := src.Watch(
		structs.NewServiceID("web-sidecar-proxy-1", nil),
		"node-name",
		"token",
	)
	require.Equal(t, limiter.ErrCapacityReached, err)
}

func testConfigManager(t *testing.T, serviceID structs.ServiceID, nodeName string, token string) *MockConfigManager {
	t.Helper()

	cfgMgr := NewMockConfigManager(t)

	proxyID := proxycfg.ProxyID{
		ServiceID: serviceID,
		NodeName:  nodeName,
		Token:     token,
	}

	snapCh := make(chan *proxycfg.ConfigSnapshot, 1)
	cfgMgr.On("Watch", proxyID).
		Return((<-chan *proxycfg.ConfigSnapshot)(snapCh), context.CancelFunc(func() {}), nil)

	cfgMgr.On("Register", mock.Anything, mock.Anything, source, token, false).
		Run(func(args mock.Arguments) {
			id := args.Get(0).(proxycfg.ProxyID)
			ns := args.Get(1).(*structs.NodeService)

			snapCh <- &proxycfg.ConfigSnapshot{
				ProxyID: id,
				Port:    ns.Port,
				Proxy:   ns.Proxy,
			}
		}).
		Return(nil)

	cfgMgr.On("Deregister", proxyID, source).
		Run(func(mock.Arguments) { close(snapCh) }).
		Return()

	return cfgMgr
}

func testStateStore(t *testing.T) *state.Store {
	t.Helper()

	gc, err := state.NewTombstoneGC(time.Second, time.Millisecond)
	require.NoError(t, err)
	return state.NewStateStoreWithEventPublisher(gc, stream.NoOpEventPublisher{})
}

func testLocalState(t *testing.T) *local.State {
	t.Helper()

	l := local.NewState(local.Config{}, hclog.NewNullLogger(), &token.Store{})
	l.TriggerSyncChanges = func() {}
	return l
}

type nullSessionLimiter struct{}

func (nullSessionLimiter) BeginSession() (limiter.Session, error) {
	return nullSession{}, nil
}

func (nullSessionLimiter) Run(ctx context.Context) {}

type nullSession struct{}

func (nullSession) End() {}

func (nullSession) Terminated() limiter.SessionTerminatedChan { return nil }

type mockSession struct {
	mock.Mock
}

func newMockSession(t *testing.T) *mockSession {
	m := &mockSession{}
	m.Test(t)

	t.Cleanup(func() { m.AssertExpectations(t) })

	return m
}

func (m *mockSession) End() { m.Called() }

func (m *mockSession) Terminated() limiter.SessionTerminatedChan {
	return m.Called().Get(0).(limiter.SessionTerminatedChan)
}
