package server

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

	"buf.build/go/protovalidate"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/gitaly"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/gitlab"
	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/errz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/logz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/prototool"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/syncz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/pkg/agentcfg"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/pkg/event"
	"gitlab.com/gitlab-org/cluster-integration/tunnel/tool/retry"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

type server struct {
	rpc.UnsafeAgentConfigurationServer
	serverAPI                  modserver.API
	gitaly                     gitaly.PoolInterface
	gitLabClient               gitlab.ClientInterface
	maxConfigurationFileSize   int64
	getConfigurationPollConfig retry.PollConfig
	gitLabExternalURL          string
	validator                  protovalidate.Validator
	// applyDefaultsToConfig updates the config with defaults before it is sent to the Rails app.
	applyDefaultsToConfig func(*agentcfg.ConfigurationFile)
}

func (s *server) GetConfiguration(req *rpc.ConfigurationRequest, server grpc.ServerStreamingServer[rpc.ConfigurationResponse]) error {
	ctx := server.Context()
	rpcAPI := modserver.AgentRPCAPIFromContext(ctx)
	log := rpcAPI.Log()

	pokeChannel := retry.NewPokeChannel()

	wh := syncz.NewComparableSyncWorkerHolder(
		func(projectID string) func() {
			return s.serverAPI.OnGitPushEvent(func(e *event.GitPushEvent) {
				// NOTE: yes, the req.ProjectId is NOT a project id, but a full project path ...
				if e.Project.FullPath == projectID {
					pokeChannel.Poke()
				}
			})
		},
	)
	defer wh.Stop()

	lastProcessedCommitID := req.CommitId
	return rpcAPI.PollWithBackoff(s.getConfigurationPollConfig, func() (error, retry.AttemptResult) { //nolint:staticcheck
		// This call is made on each poll because:
		// - it checks that the agent's token is still valid
		// - repository location in Gitaly might have changed
		a, err := rpcAPI.AgentInfo(ctx, log)
		if err != nil {
			if status.Code(err) == codes.Unavailable {
				return nil, retry.Backoff
			}
			return err, retry.Done
		}
		agentInfo, ok := a.(*server_api.AgentkInfo)
		if !ok {
			return status.Errorf(
				codes.InvalidArgument, "expected agentk info to fetch agent configuration, received: %T", a,
			), retry.Done
		}

		wh.ApplyConfig(agentInfo.Repository.GlProjectPath)
		// re-define log to avoid accidentally using the old one
		log := log.With(logz.AgentKey(agentInfo.Key), logz.ProjectID(agentInfo.Repository.GlProjectPath)) //nolint:govet
		foundCommitID := ""
		info, err := s.poll(ctx, agentInfo)
		if err != nil {
			switch gitaly.ErrorCodeFromError(err) { //nolint:exhaustive
			case gitaly.NotFound: // repo/ref not found
				log.Debug("Config: repository poll failed", logz.Error(err))
				foundCommitID = agent_configuration.RefNotFoundCommitID
				// We don't want to return an error here.
				// We want to:
				// - return an empty config to the agent so that it potentially stops using an old config.
				// - send an empty config to rails so that it erases potentially outdated info in the DB.
			default:
				if status.Code(err) != codes.Canceled {
					rpcAPI.HandleProcessingError(log, "config: repository poll failed", err, logz.AgentKey(agentInfo.Key))
				}
				return nil, retry.Backoff
			}
		} else {
			if info.CommitID == "" {
				foundCommitID = agent_configuration.RefNotFoundCommitID
			} else {
				foundCommitID = info.CommitID
			}
		}
		if lastProcessedCommitID == foundCommitID {
			log.Debug("Config: no updates", logz.CommitID(foundCommitID))
			return nil, retry.Continue
		}
		var configFile *agentcfg.ConfigurationFile
		if foundCommitID == agent_configuration.RefNotFoundCommitID {
			log.Info("Config: ref or repository not found")
			configFile = &agentcfg.ConfigurationFile{}
		} else {
			log.Info("Config: new commit", logz.CommitID(foundCommitID))
			configFile, err = s.fetchConfiguration(ctx, agentInfo, foundCommitID)
			if err != nil {
				if status.Code(err) != codes.Canceled {
					rpcAPI.HandleProcessingError(log, "Config: failed to fetch", err, logz.AgentKey(agentInfo.Key))
				}
				var ue errz.UserError
				if errors.As(err, &ue) {
					// return the error to the client because it's a user error
					return status.Errorf(codes.FailedPrecondition, "config: %v", err), retry.Done
				}
				return nil, retry.Backoff
			}
		}
		defer func() { //nolint:contextcheck
			// separate context to:
			// - set a timeout
			// - ignore RPC context - we've fetched the config, let's wait until GitLab gets it too even if agent disconnects.
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			defer cancel()
			sendCommitID := foundCommitID
			if sendCommitID == agent_configuration.RefNotFoundCommitID {
				sendCommitID = ""
			}
			s.applyDefaultsToConfig(configFile)
			err = gapi.PostAgentkConfiguration(ctx, s.gitLabClient, agentInfo.Key, configFile, sendCommitID)
			switch {
			case err == nil:
			case gitlab.IsNotFound(err):
				// Agent has been deleted from DB, but it's still running in the cluster. Don't need to send this error
				// to Sentry.
				log.Debug("Failed to notify GitLab of new agent configuration. Deleted agent?", logz.Error(err))
			default:
				rpcAPI.HandleProcessingError(log, "Failed to notify GitLab of new agent configuration", err, logz.AgentKey(agentInfo.Key))
			}
		}()
		err = s.sendConfigResponse(server, agentInfo, configFile, foundCommitID)
		if err != nil {
			return rpcAPI.HandleIOError(log, "config: failed to send config", err), retry.Done
		}
		lastProcessedCommitID = foundCommitID
		return nil, retry.Continue
	}, retry.WithPokeChannel(pokeChannel))
}

func (s *server) poll(ctx context.Context, agentInfo *server_api.AgentkInfo) (*gitaly.PollInfo, error) {
	p, err := s.gitaly.Poller(ctx, agentInfo.GitalyInfo)
	if err != nil {
		return nil, err
	}
	agentConfigurationFilePath := agent_configuration.FileForAgent(agentInfo.Name)
	return p.Poll(ctx, agentInfo.Repository, "refs/heads/"+agentInfo.DefaultBranch, agentConfigurationFilePath)
}

func (s *server) sendConfigResponse(server grpc.ServerStreamingServer[rpc.ConfigurationResponse],
	agentInfo *server_api.AgentkInfo, configFile *agentcfg.ConfigurationFile, commitID string) error {
	return server.Send(&rpc.ConfigurationResponse{
		Configuration: &agentcfg.AgentConfiguration{
			Observability:     configFile.Observability,
			AgentId:           agentInfo.Key.ID,
			ProjectId:         agentInfo.ProjectID,
			ProjectPath:       agentInfo.Repository.GlProjectPath,
			CiAccess:          configFile.CiAccess,
			ContainerScanning: configFile.ContainerScanning,
			RemoteDevelopment: configFile.RemoteDevelopment,
			Flux:              configFile.Flux,
			GitlabExternalUrl: s.gitLabExternalURL,
		},
		CommitId: commitID,
	})
}

// fetchConfiguration fetches agent's configuration from a corresponding repository.
// Assumes configuration is stored in ".gitlab/agents/<agent id>/config.yaml" file.
// fetchConfiguration returns a wrapped context.Canceled, context.DeadlineExceeded or gRPC error if ctx signals done and interrupts a running gRPC call.
func (s *server) fetchConfiguration(ctx context.Context, agentInfo *server_api.AgentkInfo, commitID string) (*agentcfg.ConfigurationFile, error) {
	pf, err := s.gitaly.PathFetcher(ctx, agentInfo.GitalyInfo)
	if err != nil {
		return nil, fmt.Errorf("PathFetcher: %w", err) // wrap
	}
	filename := agent_configuration.FileForAgent(agentInfo.Name)
	configYAML, err := pf.FetchFile(ctx, agentInfo.Repository, []byte(commitID), []byte(filename), s.maxConfigurationFileSize)
	if err != nil {
		switch gitaly.ErrorCodeFromError(err) { //nolint:exhaustive
		case gitaly.NotFound:
			configYAML = nil // Missing config is the same as empty config
		case gitaly.FileTooBig, gitaly.UnexpectedTreeEntryType:
			return nil, errz.NewUserErrorWithCause(err, "agent configuration file")
		default:
			return nil, fmt.Errorf("fetch agent configuration: %w", err) // wrap
		}
	}
	configFile := &agentcfg.ConfigurationFile{}
	err = prototool.ParseYAMLToProto(configYAML, configFile)
	if err != nil {
		return nil, errz.NewUserErrorWithCause(err, "failed to parse agent configuration")
	}
	err = s.validator.Validate(configFile)
	if err != nil {
		return nil, errz.NewUserErrorWithCause(err, "invalid agent configuration")
	}
	return configFile, nil
}
