package server

import (
	"fmt"
	"net/http"
	"sync"
	"testing"
	"time"

	"buf.build/go/protovalidate"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/gitaly"
	gapi "gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/gitlab/api"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/agent_configuration"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/agent_configuration/rpc"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/modserver"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/server_api"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/logz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/matcher"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/mock_gitlab"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/mock_internalgitaly"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/mock_modserver"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/mock_rpc"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/testhelpers"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/pkg/agentcfg"
	"go.uber.org/mock/gomock"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/encoding/protojson"
	"sigs.k8s.io/yaml"
)

const (
	revision1    = "507ebc6de9bcac25628aa7afd52802a91a0685d8"
	revision2    = "e9bcac25628aa7afd528507ebc6d02a91a0685d8"
	branchPrefix = "refs/heads/"

	maxConfigurationFileSize = 128 * 1024
)

var (
	_ modserver.Factory            = (*Factory)(nil)
	_ modserver.ApplyDefaults      = ApplyDefaults
	_ rpc.AgentConfigurationServer = (*server)(nil)
)

func TestGetConfiguration_HappyPath(t *testing.T) {
	agentInfo := testhelpers.AgentkInfoObj()
	cf := &agentcfg.ConfigurationFile{
		Observability: &agentcfg.ObservabilityCF{
			Logging: &agentcfg.LoggingCF{
				Level: agentcfg.LogLevelEnum_info,
			},
		},
	}
	cfg1Req := &gapi.AgentConfigurationRequest{
		AgentId:             agentInfo.Key.ID,
		AgentConfig:         cf,
		AgentConfigCommitId: revision1,
	}
	cfg2Req := &gapi.AgentConfigurationRequest{
		AgentId:             agentInfo.Key.ID,
		AgentConfig:         cf,
		AgentConfigCommitId: revision2,
	}
	s, ctrl, gitalyPool, resp, _ := setupServer(t, agentInfo, 2, cfg1Req, cfg2Req)
	configFile := sampleConfig()
	cfg1 := &agentcfg.AgentConfiguration{
		Observability: &agentcfg.ObservabilityCF{
			Logging: &agentcfg.LoggingCF{
				Level: agentcfg.LogLevelEnum_info,
			},
		},
		AgentId:     agentInfo.Key.ID,
		ProjectId:   agentInfo.ProjectID,
		ProjectPath: agentInfo.Repository.GlProjectPath,
	}
	p := mock_internalgitaly.NewMockPollerInterface(ctrl)
	pf := mock_internalgitaly.NewMockPathFetcherInterface(ctrl)
	configFileName := agent_configuration.FileForAgent(agentInfo.Name)
	gomock.InOrder(
		gitalyPool.EXPECT().
			Poller(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(p, nil),
		p.EXPECT().
			Poll(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), branchPrefix+agentInfo.DefaultBranch, configFileName).
			Return(&gitaly.PollInfo{
				CommitID: revision1,
			}, nil),
		gitalyPool.EXPECT().
			PathFetcher(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(pf, nil),
		pf.EXPECT().
			FetchFile(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), []byte(revision1), []byte(configFileName), int64(maxConfigurationFileSize)).
			Return(configToBytes(t, configFile), nil),
		resp.EXPECT().
			Send(matcher.ProtoEq(t, &rpc.ConfigurationResponse{
				Configuration: cfg1,
				CommitId:      revision1,
			})),
		gitalyPool.EXPECT().
			Poller(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(p, nil),
		p.EXPECT().
			Poll(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), branchPrefix+agentInfo.DefaultBranch, configFileName).
			Return(&gitaly.PollInfo{
				CommitID: revision2,
			}, nil),
		gitalyPool.EXPECT().
			PathFetcher(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(pf, nil),
		pf.EXPECT().
			FetchFile(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), []byte(revision2), []byte(configFileName), int64(maxConfigurationFileSize)).
			Return(configToBytes(t, configFile), nil),
		resp.EXPECT().
			Send(matcher.ProtoEq(t, &rpc.ConfigurationResponse{
				Configuration: cfg1,
				CommitId:      revision2,
			})),
	)
	err := s.GetConfiguration(&rpc.ConfigurationRequest{}, resp)
	require.NoError(t, err)
}

func TestGetConfiguration_ResumeConnection(t *testing.T) {
	agentInfo := testhelpers.AgentkInfoObj()
	s, ctrl, gitalyPool, resp, _ := setupServer(t, agentInfo, 1)
	p := mock_internalgitaly.NewMockPollerInterface(ctrl)
	configFileName := agent_configuration.FileForAgent(agentInfo.Name)
	gomock.InOrder(
		gitalyPool.EXPECT().
			Poller(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(p, nil),
		p.EXPECT().
			Poll(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), branchPrefix+agentInfo.DefaultBranch, configFileName).
			Return(&gitaly.PollInfo{
				CommitID: revision1,
			}, nil),
	)
	err := s.GetConfiguration(&rpc.ConfigurationRequest{
		CommitId: revision1, // same commit id
	}, resp)
	require.NoError(t, err)
}

func TestGetConfiguration_ResumeConnectionFromRefNotFound(t *testing.T) {
	agentInfo := testhelpers.AgentkInfoObj()
	s, ctrl, gitalyPool, resp, _ := setupServer(t, agentInfo, 1)
	p := mock_internalgitaly.NewMockPollerInterface(ctrl)
	configFileName := agent_configuration.FileForAgent(agentInfo.Name)
	gomock.InOrder(
		gitalyPool.EXPECT().
			Poller(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(p, nil),
		p.EXPECT().
			Poll(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), branchPrefix+agentInfo.DefaultBranch, configFileName).
			Return(&gitaly.PollInfo{
				CommitID: "",
			}, nil),
	)
	err := s.GetConfiguration(&rpc.ConfigurationRequest{
		CommitId: agent_configuration.RefNotFoundCommitID, // same ref not found commit id
	}, resp)
	require.NoError(t, err)
}

func TestGetConfiguration_RefNotFound(t *testing.T) {
	agentInfo := testhelpers.AgentkInfoObj()
	cfg1Req := &gapi.AgentConfigurationRequest{
		AgentId:             agentInfo.Key.ID,
		AgentConfig:         &agentcfg.ConfigurationFile{},
		AgentConfigCommitId: "", // empty commit ID for ref not found
	}
	s, ctrl, gitalyPool, resp, _ := setupServer(t, agentInfo, 1, cfg1Req)
	resp.EXPECT().
		Send(matcher.ProtoEq(t, &rpc.ConfigurationResponse{
			Configuration: &agentcfg.AgentConfiguration{
				AgentId:     agentInfo.Key.ID,
				ProjectId:   agentInfo.ProjectID,
				ProjectPath: agentInfo.Repository.GlProjectPath,
			},
			CommitId: agent_configuration.RefNotFoundCommitID,
		}))
	p := mock_internalgitaly.NewMockPollerInterface(ctrl)
	configFileName := agent_configuration.FileForAgent(agentInfo.Name)
	gomock.InOrder(
		gitalyPool.EXPECT().
			Poller(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(p, nil),
		p.EXPECT().
			Poll(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), branchPrefix+agentInfo.DefaultBranch, configFileName).
			Return(nil, gitaly.NewNotFoundError("Bla", "some/ref")),
	)
	err := s.GetConfiguration(&rpc.ConfigurationRequest{}, resp)
	require.NoError(t, err)
}

func TestGetConfiguration_ConfigNotFound(t *testing.T) {
	agentInfo := testhelpers.AgentkInfoObj()
	cfg1Req := &gapi.AgentConfigurationRequest{
		AgentId:             agentInfo.Key.ID,
		AgentConfig:         &agentcfg.ConfigurationFile{},
		AgentConfigCommitId: revision1,
	}
	s, ctrl, gitalyPool, resp, _ := setupServer(t, agentInfo, 1, cfg1Req)
	resp.EXPECT().
		Send(matcher.ProtoEq(t, &rpc.ConfigurationResponse{
			Configuration: &agentcfg.AgentConfiguration{
				AgentId:     agentInfo.Key.ID,
				ProjectId:   agentInfo.ProjectID,
				ProjectPath: agentInfo.Repository.GlProjectPath,
			},
			CommitId: revision1,
		}))
	p := mock_internalgitaly.NewMockPollerInterface(ctrl)
	pf := mock_internalgitaly.NewMockPathFetcherInterface(ctrl)
	configFileName := agent_configuration.FileForAgent(agentInfo.Name)
	gomock.InOrder(
		gitalyPool.EXPECT().
			Poller(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(p, nil),
		p.EXPECT().
			Poll(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), branchPrefix+agentInfo.DefaultBranch, configFileName).
			Return(&gitaly.PollInfo{
				CommitID: revision1,
			}, nil),
		gitalyPool.EXPECT().
			PathFetcher(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(pf, nil),
		pf.EXPECT().
			FetchFile(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), []byte(revision1), []byte(configFileName), int64(maxConfigurationFileSize)).
			Return(nil, gitaly.NewNotFoundError("Bla", "some/file")),
	)
	err := s.GetConfiguration(&rpc.ConfigurationRequest{}, resp)
	require.NoError(t, err)
}

func TestGetConfiguration_EmptyRepository(t *testing.T) {
	agentInfo := testhelpers.AgentkInfoObj()
	cfg1Req := &gapi.AgentConfigurationRequest{
		AgentId:             agentInfo.Key.ID,
		AgentConfig:         &agentcfg.ConfigurationFile{},
		AgentConfigCommitId: "", // empty commit ID for an empty repo
	}
	s, ctrl, gitalyPool, resp, _ := setupServer(t, agentInfo, 1, cfg1Req)
	resp.EXPECT().
		Send(matcher.ProtoEq(t, &rpc.ConfigurationResponse{
			Configuration: &agentcfg.AgentConfiguration{
				AgentId:     agentInfo.Key.ID,
				ProjectId:   agentInfo.ProjectID,
				ProjectPath: agentInfo.Repository.GlProjectPath,
			},
			CommitId: agent_configuration.RefNotFoundCommitID,
		}))
	p := mock_internalgitaly.NewMockPollerInterface(ctrl)
	configFileName := agent_configuration.FileForAgent(agentInfo.Name)
	gomock.InOrder(
		gitalyPool.EXPECT().
			Poller(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
			Return(p, nil),
		p.EXPECT().
			Poll(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), branchPrefix+agentInfo.DefaultBranch, configFileName).
			Return(&gitaly.PollInfo{}, nil),
	)
	err := s.GetConfiguration(&rpc.ConfigurationRequest{}, resp)
	require.NoError(t, err)
}

func TestGetConfiguration_UserErrors(t *testing.T) {
	gitalyErrs := []error{
		gitaly.NewFileTooBigError(nil, "Bla", "some/file"),
		gitaly.NewUnexpectedTreeEntryTypeError("Bla", "some/file"),
	}
	for _, gitalyErr := range gitalyErrs {
		t.Run(gitalyErr.(*gitaly.Error).Code.String(), func(t *testing.T) { //nolint: errorlint
			agentInfo := testhelpers.AgentkInfoObj()
			s, ctrl, gitalyPool, resp, mockRPCAPI := setupServer(t, agentInfo, 1)
			p := mock_internalgitaly.NewMockPollerInterface(ctrl)
			pf := mock_internalgitaly.NewMockPathFetcherInterface(ctrl)
			configFileName := agent_configuration.FileForAgent(agentInfo.Name)
			gomock.InOrder(
				gitalyPool.EXPECT().
					Poller(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
					Return(p, nil),
				p.EXPECT().
					Poll(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), branchPrefix+agentInfo.DefaultBranch, configFileName).
					Return(&gitaly.PollInfo{
						CommitID: revision1,
					}, nil),
				gitalyPool.EXPECT().
					PathFetcher(gomock.Any(), matcher.ProtoEq(nil, agentInfo.GitalyInfo)).
					Return(pf, nil),
				pf.EXPECT().
					FetchFile(gomock.Any(), matcher.ProtoEq(nil, agentInfo.Repository), []byte(revision1), []byte(configFileName), int64(maxConfigurationFileSize)).
					Return(nil, gitalyErr),
				mockRPCAPI.EXPECT().
					HandleProcessingError(gomock.Any(), "Config: failed to fetch", matcher.ErrorEq(fmt.Sprintf("agent configuration file: %v", gitalyErr)),
						logz.AgentKey(testhelpers.AgentkKey1)),
			)
			err := s.GetConfiguration(&rpc.ConfigurationRequest{}, resp)
			assert.EqualError(t, err, fmt.Sprintf("rpc error: code = FailedPrecondition desc = config: agent configuration file: %v", gitalyErr))
		})
	}
}

func TestGetConfiguration_GetAgentInfo_Error(t *testing.T) {
	s, _, _, resp, mockRPCAPI, _ := setupServerBare(t, 1)
	mockRPCAPI.EXPECT().
		AgentInfo(gomock.Any(), gomock.Any()).
		Return(nil, status.Error(codes.PermissionDenied, "expected err")) // code doesn't matter, we test that we return on error
	err := s.GetConfiguration(&rpc.ConfigurationRequest{}, resp)
	assert.EqualError(t, err, "rpc error: code = PermissionDenied desc = expected err")
}

func TestGetConfiguration_GetAgentInfo_RetriableError(t *testing.T) {
	s, _, _, resp, mockRPCAPI, _ := setupServerBare(t, 2)
	gomock.InOrder(
		mockRPCAPI.EXPECT().
			AgentInfo(gomock.Any(), gomock.Any()).
			Return(nil, status.Error(codes.Unavailable, "unavailable")),
		mockRPCAPI.EXPECT().
			AgentInfo(gomock.Any(), gomock.Any()).
			Return(nil, status.Error(codes.PermissionDenied, "expected err")), // code doesn't matter, we test that we return on error
	)
	err := s.GetConfiguration(&rpc.ConfigurationRequest{}, resp)
	assert.EqualError(t, err, "rpc error: code = PermissionDenied desc = expected err")
}

func setupServerBare(t *testing.T, pollTimes int) (*server, *gomock.Controller, *mock_internalgitaly.MockPoolInterface, *mock_rpc.MockAgentConfiguration_GetConfigurationServer, *mock_modserver.MockAgentRPCAPI, *mock_modserver.MockAPI) {
	return setupServerWithConfigCallback(t, pollTimes, func(r *gapi.AgentConfigurationRequest) bool {
		return true
	})
}

func setupServerWithConfigCallback(t *testing.T, pollTimes int, cb func(*gapi.AgentConfigurationRequest) bool) (*server, *gomock.Controller, *mock_internalgitaly.MockPoolInterface, *mock_rpc.MockAgentConfiguration_GetConfigurationServer, *mock_modserver.MockAgentRPCAPI, *mock_modserver.MockAPI) {
	ctrl := gomock.NewController(t)
	mockRPCAPI := mock_modserver.NewMockAgentRPCAPIWithMockPoller(ctrl, pollTimes)
	mockAPI := mock_modserver.NewMockAPI(ctrl)
	gitalyPool := mock_internalgitaly.NewMockPoolInterface(ctrl)
	gitLabClient := mock_gitlab.SetupClient(t, "", gapi.AgentConfigurationAPIPath, func(w http.ResponseWriter, r *http.Request) {
		testhelpers.AssertRequestMethod(t, r, http.MethodPost)
		testhelpers.AssertJWTSignature(t, r)

		cfg := &gapi.AgentConfigurationRequest{}
		if !testhelpers.ReadProtoJSONRequest(t, r, cfg) {
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		assert.Equal(t, testhelpers.AgentkKey1.ID, cfg.AgentId)
		if !cb(cfg) {
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	})
	v, err := protovalidate.New()
	require.NoError(t, err)

	s := &server{
		serverAPI:                  mockAPI,
		gitaly:                     gitalyPool,
		gitLabClient:               gitLabClient,
		getConfigurationPollConfig: testhelpers.NewPollConfig(10 * time.Minute),
		maxConfigurationFileSize:   maxConfigurationFileSize,
		validator:                  v,
		applyDefaultsToConfig:      func(config *agentcfg.ConfigurationFile) {},
	}
	resp := mock_rpc.NewMockAgentConfiguration_GetConfigurationServer(ctrl)
	resp.EXPECT().
		Context().
		Return(mock_modserver.IncomingAgentCtx(t, mockRPCAPI)).
		MinTimes(1)
	return s, ctrl, gitalyPool, resp, mockRPCAPI, mockAPI
}

func setupServer(t *testing.T, agentInfo server_api.AgentInfo, pollTimes int, expectedCfgs ...*gapi.AgentConfigurationRequest) (*server, *gomock.Controller, *mock_internalgitaly.MockPoolInterface, *mock_rpc.MockAgentConfiguration_GetConfigurationServer, *mock_modserver.MockAgentRPCAPI) {
	var mu sync.Mutex
	s, ctrl, gitalyPool, resp, mockRPCAPI, mockAPI := setupServerWithConfigCallback(t, pollTimes, func(r *gapi.AgentConfigurationRequest) bool {
		mu.Lock()
		defer mu.Unlock()
		if !assert.NotEmpty(t, expectedCfgs, "Got config", protojson.Format(r)) {
			return false
		}
		expcfg := expectedCfgs[0]
		expectedCfgs = expectedCfgs[1:]

		return matcher.AssertProtoEqual(t, expcfg, r)
	})
	mockRPCAPI.EXPECT().
		AgentInfo(gomock.Any(), gomock.Any()).
		Return(agentInfo, nil).
		Times(pollTimes)
	mockAPI.EXPECT().
		OnGitPushEvent(gomock.Any()).
		Return(func() {})
	return s, ctrl, gitalyPool, resp, mockRPCAPI
}

func configToBytes(t *testing.T, configFile *agentcfg.ConfigurationFile) []byte {
	configJSON, err := protojson.Marshal(configFile)
	require.NoError(t, err)
	configYAML, err := yaml.JSONToYAML(configJSON)
	require.NoError(t, err)
	return configYAML
}

func sampleConfig() *agentcfg.ConfigurationFile {
	return &agentcfg.ConfigurationFile{
		Observability: &agentcfg.ObservabilityCF{
			Logging: &agentcfg.LoggingCF{
				Level: agentcfg.LogLevelEnum_info,
			},
		},
	}
}
