// Copyright 2023 Woodpecker Authors
// Copyright 2018 Drone.IO Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package core

import (
	"context"
	"errors"
	"fmt"
	"maps"
	"net/http"
	"os"
	"strings"
	"sync/atomic"
	"time"

	"github.com/rs/zerolog/log"
	"github.com/urfave/cli/v3"
	"golang.org/x/sync/errgroup"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"

	"go.woodpecker-ci.org/woodpecker/v3/agent"
	agent_rpc "go.woodpecker-ci.org/woodpecker/v3/agent/rpc"
	"go.woodpecker-ci.org/woodpecker/v3/pipeline"
	"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend"
	"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
	"go.woodpecker-ci.org/woodpecker/v3/rpc"
	"go.woodpecker-ci.org/woodpecker/v3/shared/logger"
	"go.woodpecker-ci.org/woodpecker/v3/shared/utils"
	"go.woodpecker-ci.org/woodpecker/v3/version"
)

const (
	reportHealthInterval           = time.Second * 10
	authInterceptorRefreshInterval = time.Minute * 30
)

func run(ctx context.Context, c *cli.Command, backends []types.Backend) error {
	log.Info().Str("version", version.String()).Msg("Starting Woodpecker agent")

	agentCtx, ctxCancel := context.WithCancelCause(ctx)
	defer func() {
		log.Info().Msg("shutdown of whole agent")
		ctxCancel(nil)
	}()

	serviceWaitingGroup := errgroup.Group{}

	agentConfigPath := c.String("agent-config")
	hostname := c.String("hostname")
	if len(hostname) == 0 {
		hostname, _ = os.Hostname()
	}

	maxWorkflows := c.Int("max-workflows")
	singleWorkflow := c.Bool("single-workflow")
	if singleWorkflow && maxWorkflows > 1 {
		log.Warn().Msgf("max-workflows forced from %d to 1 due to agent running single workflow mode.", maxWorkflows)
		maxWorkflows = 1
	}

	counter.Polling = maxWorkflows
	counter.Running = 0

	if c.Bool("healthcheck") {
		serviceWaitingGroup.Go(
			func() error {
				server := &http.Server{Addr: c.String("healthcheck-addr")}
				go func() {
					<-agentCtx.Done()
					log.Info().Msg("shutdown healthcheck server ...")

					shutdownCtx, shutdownCtxCancel := agent.GetShutdownContext()
					defer shutdownCtxCancel()

					if err := server.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck
						log.Error().Err(err).Msg("shutdown healthcheck server failed")
					} else {
						log.Info().Msg("healthcheck server stopped")
					}
				}()
				if err := server.ListenAndServe(); err != nil {
					log.Error().Err(err).Msgf("cannot listen on address %s", c.String("healthcheck-addr"))
				}
				return nil
			},
		)
	}

	agentConfig := readAgentConfig(agentConfigPath)

	grpcClientCtx, grpcClientCtxCancel := context.WithCancelCause(context.Background())
	defer grpcClientCtxCancel(nil)

	if c.Bool("grpc-secure") {
		log.Trace().Msg("use ssl for grpc")
	}

	agentConn, err := agent_rpc.Dial(grpcClientCtx, agent_rpc.DialConfig{ //nolint:contextcheck
		ServerAddr:       c.String("server"),
		AgentToken:       c.String("grpc-token"),
		AgentID:          agentConfig.AgentID,
		Secure:           c.Bool("grpc-secure"),
		SkipTLSVerify:    c.Bool("grpc-skip-insecure"),
		KeepaliveTime:    c.Duration("grpc-keepalive-time"),
		KeepaliveTimeout: c.Duration("grpc-keepalive-timeout"),
		AuthRefreshEvery: authInterceptorRefreshInterval,
	})
	if err != nil {
		return err
	}
	defer agentConn.Close()

	// Persist the agent ID received during auth so that crashloops reuse the
	// same server-side entry instead of creating a new one on every restart.
	if agentConfigPath != "" {
		agentConfig.AgentID = agentConn.AgentID
		if err := writeAgentConfig(agentConfig, agentConfigPath); err == nil {
			log.Debug().Msgf("persisted agent ID %d after auth", agentConfig.AgentID)
		}
	}

	client := agent_rpc.NewGrpcClient(
		ctx, agentConn.MainConn,
		agent_rpc.SetConnectionRetryTimeout(c.Duration("retry-timeout")),
		agent_rpc.SetLogEntryBufferSize(c.Int("log-entry-stream-buffer-size")),
	)
	agentConfigPersisted := atomic.Bool{}

	grpcCtx := metadata.NewOutgoingContext(grpcClientCtx, metadata.Pairs("hostname", hostname))

	// check if grpc server version is compatible with agent
	grpcServerVersion, err := client.Version(grpcCtx) //nolint:contextcheck
	if err != nil {
		log.Error().Err(err).Msg("could not get grpc server version")
		return err
	}
	if grpcServerVersion.GrpcVersion != agent_rpc.ClientGrpcVersion {
		err := errors.New("GRPC version mismatch")
		log.Error().Err(err).Msgf("server version %s does report grpc version %d but we only understand %d",
			grpcServerVersion.ServerVersion,
			grpcServerVersion.GrpcVersion,
			agent_rpc.ClientGrpcVersion)
		return err
	}

	// new engine
	backendCtx := context.WithValue(agentCtx, types.CliCommand, c)
	backendName := c.String("backend-engine")
	backendEngine, err := backend.FindBackend(backendCtx, backends, backendName)
	if err != nil {
		log.Error().Err(err).Msgf("cannot find backend engine '%s'", backendName)
		return err
	}
	if !backendEngine.IsAvailable(backendCtx) {
		log.Error().Str("engine", backendEngine.Name()).Msg("selected backend engine is unavailable")
		return fmt.Errorf("selected backend engine %s is unavailable", backendEngine.Name())
	}

	// load engine (e.g. init api client)
	engInfo, err := backendEngine.Load(backendCtx)
	if err != nil {
		log.Error().Err(err).Msg("cannot load backend engine")
		return err
	}
	log.Debug().Msgf("loaded %s backend engine", backendEngine.Name())

	customLabels := make(map[string]string)
	if err := stringSliceAddToMap(c.StringSlice("labels"), customLabels); err != nil {
		return err
	}
	if len(customLabels) != 0 {
		log.Debug().Msgf("custom labels detected: %#v", customLabels)
	}

	agentConfig.AgentID, err = client.RegisterAgent(grpcCtx, rpc.AgentInfo{ //nolint:contextcheck
		Version:      version.String(),
		Backend:      backendEngine.Name(),
		Platform:     engInfo.Platform,
		Capacity:     maxWorkflows,
		CustomLabels: customLabels,
	})
	if err != nil {
		return err
	}

	serviceWaitingGroup.Go(func() error {
		// we close grpc client context once unregister was handled
		defer grpcClientCtxCancel(nil)
		// we wait till agent context is done
		<-agentCtx.Done()
		// Remove stateless agents from server
		if !agentConfigPersisted.Load() {
			if client.IsConnected() {
				log.Debug().Msg("unregister agent from server ...")
				err := client.UnregisterAgent(grpcClientCtx)
				if err != nil {
					log.Err(err).Msg("failed to unregister agent from server")
				} else {
					log.Info().Msg("agent unregistered from server")
				}
			} else {
				log.Debug().Msg("skipping unregister: server not connected")
			}
		}
		return nil
	})

	if agentConfigPath != "" {
		if err := writeAgentConfig(agentConfig, agentConfigPath); err == nil {
			agentConfigPersisted.Store(true)
		}
	}

	// set default labels ...
	labels := make(map[string]string)
	labels[pipeline.LabelFilterHostname] = hostname
	labels[pipeline.LabelFilterPlatform] = engInfo.Platform
	labels[pipeline.LabelFilterBackend] = backendEngine.Name()
	labels[pipeline.LabelFilterRepo] = "*" // allow all repos by default
	// ... and let it overwrite by custom ones
	maps.Copy(labels, customLabels)

	log.Debug().Any("labels", labels).Msgf("agent configured with labels")

	filter := rpc.Filter{
		Labels: labels,
	}

	log.Debug().Msgf("agent registered with ID %d", agentConfig.AgentID)

	serviceWaitingGroup.Go(func() error {
		for {
			err := client.ReportHealth(grpcCtx)
			if err != nil {
				log.Err(err).Msg("failed to report health")
				// Check if the error is due to context cancellation
				if grpcCtx.Err() != nil || agentCtx.Err() != nil {
					log.Debug().Msg("terminating health reporting due to context cancellation")
					return nil
				}
			}

			select {
			case <-agentCtx.Done():
				log.Debug().Msg("terminating health reporting")
				return nil
			case <-time.After(reportHealthInterval):
			}
		}
	})

	// https://go.dev/blog/go1.22 fixed scope for goroutines in loops
	for i := range maxWorkflows {
		serviceWaitingGroup.Go(func() error {
			runner := agent.NewRunner(client, filter, hostname, counter, backendEngine)
			log.Debug().Msgf("created new runner %d", i)

			for {
				if agentCtx.Err() != nil {
					return nil
				}

				log.Debug().Msg("polling new workflow")
				if err := runner.Run(agentCtx); err != nil {
					if errors.Is(err, agent_rpc.ErrConnectionLost) {
						log.Error().Err(err).Msg("connection to server lost, shutting down agent")
						ctxCancel(err)
						return nil
					}
					if singleWorkflow {
						log.Error().Err(err).Msg("runner done with error")
						ctxCancel(nil)
						return nil
					}
					log.Error().Err(err).Msg("runner error, retrying...")
					// Check if context is canceled
					if agentCtx.Err() != nil {
						return nil
					}
					// Wait a bit before retrying to avoid hammering the server
					select {
					case <-agentCtx.Done():
						return nil
					case <-time.After(time.Second * 5):
						// Continue to next iteration
					}
				}

				if singleWorkflow {
					log.Info().Msg("shutdown single workflow runner")
					ctxCancel(nil)
					return nil
				}
			}
		})
	}

	log.Info().
		Str("version", version.String()).
		Str("backend", backendEngine.Name()).
		Str("platform", engInfo.Platform).
		Int("parallel workflows", maxWorkflows).
		Bool("single workflow", singleWorkflow).
		Msg("starting Woodpecker agent")

	return serviceWaitingGroup.Wait()
}

func runWithRetry(backendEngines []types.Backend) func(ctx context.Context, c *cli.Command) error {
	return func(ctx context.Context, c *cli.Command) error {
		if err := logger.SetupGlobalLogger(ctx, c, true); err != nil {
			return err
		}

		initHealth()

		retryCount := c.Int("connect-retry-count")
		retryDelay := c.Duration("connect-retry-delay")
		var err error
		for range retryCount {
			if err = run(ctx, c, backendEngines); status.Code(err) == codes.Unavailable {
				log.Warn().Err(err).Msg(fmt.Sprintf("cannot connect to %s, retrying in %v", c.String("server"), retryDelay))
				time.Sleep(retryDelay)
			} else {
				break
			}
		}
		return err
	}
}

func stringSliceAddToMap(sl []string, m map[string]string) error {
	if m == nil {
		m = make(map[string]string)
	}
	for _, v := range utils.StringSliceDeleteEmpty(sl) {
		before, after, _ := strings.Cut(v, "=")
		switch {
		case before != "" && after != "":
			m[before] = after
		case before != "":
			return fmt.Errorf("key '%s' does not have a value assigned", before)
		default:
			return fmt.Errorf("empty string in slice")
		}
	}
	return nil
}
