// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2019 nektos
// SPDX-License-Identifier: MIT

package cmd

import (
	"context"
	"fmt"
	"maps"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"code.forgejo.org/forgejo/runner/v12/act/artifactcache"
	"code.forgejo.org/forgejo/runner/v12/act/common"
	"code.forgejo.org/forgejo/runner/v12/act/model"
	"code.forgejo.org/forgejo/runner/v12/act/runner"
	"github.com/docker/docker/api/types/container"
	"github.com/joho/godotenv"
	log "github.com/sirupsen/logrus"
	"github.com/spf13/cobra"
	"golang.org/x/term"
)

type executeArgs struct {
	runList               bool
	job                   string
	event                 string
	workdir               string
	workflowsPath         string
	noWorkflowRecurse     bool
	autodetectEvent       bool
	forcePull             bool
	forceRebuild          bool
	jsonLogger            bool
	envs                  []string
	envfile               string
	secrets               []string
	vars                  []string
	defaultActionsURL     string
	insecureSecrets       bool
	privileged            bool
	usernsMode            string
	containerArchitecture string
	containerDaemonSocket string
	useGitIgnore          bool
	containerCapAdd       []string
	containerCapDrop      []string
	containerOptions      string
	noSkipCheckout        bool
	debug                 bool
	dryrun                bool
	image                 string
	cacheHandler          artifactcache.Handler
	network               string
	enableIPv6            bool
	githubInstance        string
}

// WorkflowsPath returns path to workflow file(s)
func (i *executeArgs) WorkflowsPath() string {
	return i.resolve(i.workflowsPath)
}

// Envfile returns path to .env
func (i *executeArgs) Envfile() string {
	return i.resolve(i.envfile)
}

//nolint:forbidigo
func (i *executeArgs) LoadSecrets() map[string]string {
	s := make(map[string]string)
	for _, secretPair := range i.secrets {
		secretPairParts := strings.SplitN(secretPair, "=", 2)
		secretPairParts[0] = strings.ToUpper(secretPairParts[0])
		if strings.ToUpper(s[secretPairParts[0]]) == secretPairParts[0] {
			log.Errorf("Secret %s is already defined (secrets are case insensitive)", secretPairParts[0])
		}
		if len(secretPairParts) == 2 {
			s[secretPairParts[0]] = secretPairParts[1]
		} else if env, ok := os.LookupEnv(secretPairParts[0]); ok && env != "" {
			s[secretPairParts[0]] = env
		} else {
			fmt.Printf("Provide value for '%s': ", secretPairParts[0])
			val, err := term.ReadPassword(int(os.Stdin.Fd()))
			fmt.Println()
			if err != nil {
				log.Errorf("failed to read input: %v", err)
				os.Exit(1)
			}
			s[secretPairParts[0]] = string(val)
		}
	}
	return s
}

func readEnvs(path string, envs map[string]string) bool {
	if _, err := os.Stat(path); err == nil {
		env, err := godotenv.Read(path)
		if err != nil {
			log.Fatalf("Error loading from %s: %v", path, err)
		}
		maps.Copy(envs, env)
		return true
	}
	return false
}

func (i *executeArgs) LoadEnvs() map[string]string {
	envs := make(map[string]string)
	if i.envs != nil {
		for _, envVar := range i.envs {
			e := strings.SplitN(envVar, `=`, 2)
			if len(e) == 2 {
				envs[e[0]] = e[1]
			} else {
				envs[e[0]] = ""
			}
		}
	}
	_ = readEnvs(i.Envfile(), envs)

	envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"

	return envs
}

func (i *executeArgs) LoadVars() map[string]string {
	vars := make(map[string]string)
	if i.vars != nil {
		for _, runVar := range i.vars {
			e := strings.SplitN(runVar, `=`, 2)
			if len(e) == 2 {
				vars[e[0]] = e[1]
			} else {
				vars[e[0]] = ""
			}
		}
	}

	return vars
}

// Workdir returns path to workdir
func (i *executeArgs) Workdir() string {
	return i.resolve(".")
}

func (i *executeArgs) resolve(path string) string {
	basedir, err := filepath.Abs(i.workdir)
	if err != nil {
		log.Fatal(err)
	}
	if path == "" {
		return path
	}
	if !filepath.IsAbs(path) {
		path = filepath.Join(basedir, path)
	}
	return path
}

//nolint:forbidigo
func printList(plan *model.Plan) error {
	type lineInfoDef struct {
		jobID   string
		jobName string
		stage   string
		wfName  string
		wfFile  string
		events  string
	}
	lineInfos := []lineInfoDef{}

	header := lineInfoDef{
		jobID:   "Job ID",
		jobName: "Job name",
		stage:   "Stage",
		wfName:  "Workflow name",
		wfFile:  "Workflow file",
		events:  "Events",
	}

	jobs := map[string]bool{}
	duplicateJobIDs := false

	jobIDMaxWidth := len(header.jobID)
	jobNameMaxWidth := len(header.jobName)
	stageMaxWidth := len(header.stage)
	wfNameMaxWidth := len(header.wfName)
	wfFileMaxWidth := len(header.wfFile)
	eventsMaxWidth := len(header.events)

	for i, stage := range plan.Stages {
		for _, r := range stage.Runs {
			jobID := r.JobID
			line := lineInfoDef{
				jobID:   jobID,
				jobName: r.String(),
				stage:   strconv.Itoa(i),
				wfName:  r.Workflow.Name,
				wfFile:  r.Workflow.File,
				events:  strings.Join(r.Workflow.On(), `,`),
			}
			if _, ok := jobs[jobID]; ok {
				duplicateJobIDs = true
			} else {
				jobs[jobID] = true
			}
			lineInfos = append(lineInfos, line)
			if jobIDMaxWidth < len(line.jobID) {
				jobIDMaxWidth = len(line.jobID)
			}
			if jobNameMaxWidth < len(line.jobName) {
				jobNameMaxWidth = len(line.jobName)
			}
			if stageMaxWidth < len(line.stage) {
				stageMaxWidth = len(line.stage)
			}
			if wfNameMaxWidth < len(line.wfName) {
				wfNameMaxWidth = len(line.wfName)
			}
			if wfFileMaxWidth < len(line.wfFile) {
				wfFileMaxWidth = len(line.wfFile)
			}
			if eventsMaxWidth < len(line.events) {
				eventsMaxWidth = len(line.events)
			}
		}
	}

	jobIDMaxWidth += 2
	jobNameMaxWidth += 2
	stageMaxWidth += 2
	wfNameMaxWidth += 2
	wfFileMaxWidth += 2

	fmt.Printf("%*s%*s%*s%*s%*s%*s\n",
		-stageMaxWidth, header.stage,
		-jobIDMaxWidth, header.jobID,
		-jobNameMaxWidth, header.jobName,
		-wfNameMaxWidth, header.wfName,
		-wfFileMaxWidth, header.wfFile,
		-eventsMaxWidth, header.events,
	)
	for _, line := range lineInfos {
		fmt.Printf("%*s%*s%*s%*s%*s%*s\n",
			-stageMaxWidth, line.stage,
			-jobIDMaxWidth, line.jobID,
			-jobNameMaxWidth, line.jobName,
			-wfNameMaxWidth, line.wfName,
			-wfFileMaxWidth, line.wfFile,
			-eventsMaxWidth, line.events,
		)
	}
	if duplicateJobIDs {
		fmt.Print("\nDetected multiple jobs with the same job name, use `-W` to specify the path to the specific workflow.\n")
	}
	return nil
}

func runExecList(ctx context.Context, planner model.WorkflowPlanner, execArgs *executeArgs) error {
	// plan with filtered jobs - to be used for filtering only
	var filterPlan *model.Plan

	// Determine the event name to be filtered
	var filterEventName string

	if len(execArgs.event) > 0 {
		log.Infof("Using chosen event for filtering: %s", execArgs.event)
		filterEventName = execArgs.event
	} else if execArgs.autodetectEvent {
		// collect all events from loaded workflows
		events := planner.GetEvents()

		// set default event type to first event from many available
		// this way user dont have to specify the event.
		log.Infof("Using first detected workflow event for filtering: %s", events[0])

		filterEventName = events[0]
	}

	var err error
	if execArgs.job != "" {
		log.Infof("Preparing plan with a job: %s", execArgs.job)
		filterPlan, err = planner.PlanJob(execArgs.job)
		if err != nil {
			return err
		}
	} else if filterEventName != "" {
		log.Infof("Preparing plan for a event: %s", filterEventName)
		filterPlan, err = planner.PlanEvent(filterEventName)
		if err != nil {
			return err
		}
	} else {
		log.Infof("Preparing plan with all jobs")
		filterPlan, err = planner.PlanAll()
		if err != nil {
			return err
		}
	}

	_ = printList(filterPlan)

	return nil
}

func runExec(ctx context.Context, execArgs *executeArgs) error {
	planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse, true)
	if err != nil {
		return err
	}

	if execArgs.runList {
		return runExecList(ctx, planner, execArgs)
	}

	// plan with triggered jobs
	var plan *model.Plan

	// Determine the event name to be triggered
	var eventName string

	// collect all events from loaded workflows
	events := planner.GetEvents()

	if len(execArgs.event) > 0 {
		log.Infof("Using chosen event for filtering: %s", execArgs.event)
		eventName = execArgs.event
	} else if len(events) == 1 && len(events[0]) > 0 {
		log.Infof("Using the only detected workflow event: %s", events[0])
		eventName = events[0]
	} else if execArgs.autodetectEvent && len(events) > 0 && len(events[0]) > 0 {
		// set default event type to first event from many available
		// this way user dont have to specify the event.
		log.Infof("Using first detected workflow event: %s", events[0])
		eventName = events[0]
	} else {
		log.Infof("Using default workflow event: push")
		eventName = "push"
	}

	// build the plan for this run
	if execArgs.job != "" {
		log.Infof("Planning job: %s", execArgs.job)
		plan, err = planner.PlanJob(execArgs.job)
		if err != nil {
			return err
		}
	} else {
		log.Infof("Planning jobs for event: %s", eventName)
		plan, err = planner.PlanEvent(eventName)
		if err != nil {
			return err
		}
	}

	maxLifetime := 3 * time.Hour
	if deadline, ok := ctx.Deadline(); ok {
		maxLifetime = time.Until(deadline)
	}

	// init a cache server
	handler, err := artifactcache.StartHandler("", "", 0, "", log.StandardLogger().WithField("module", "cache_request"))
	if err != nil {
		return err
	}
	defer handler.Close()
	log.Infof("cache handler listens on: %v", handler.ExternalURL())
	execArgs.cacheHandler = handler

	if execArgs.containerDaemonSocket != "/var/run/docker.sock" {
		log.Warnf("--container-daemon-socket %s: please use the DOCKER_HOST environment variable as documented at https://forgejo.org/docs/next/admin/actions/runner-installation/#setting-up-the-container-environment instead. See https://code.forgejo.org/forgejo/runner/issues/577 for more information.", execArgs.containerDaemonSocket)
	}

	// run the plan
	config := &runner.Config{
		Workdir:               execArgs.Workdir(),
		BindWorkdir:           false,
		ReuseContainers:       false,
		ForcePull:             execArgs.forcePull,
		ForceRebuild:          execArgs.forceRebuild,
		LogOutput:             true,
		JSONLogger:            execArgs.jsonLogger,
		Env:                   execArgs.LoadEnvs(),
		Vars:                  execArgs.LoadVars(),
		Secrets:               execArgs.LoadSecrets(),
		InsecureSecrets:       execArgs.insecureSecrets,
		Privileged:            execArgs.privileged,
		UsernsMode:            execArgs.usernsMode,
		ContainerArchitecture: execArgs.containerArchitecture,
		ContainerDaemonSocket: execArgs.containerDaemonSocket,
		UseGitIgnore:          execArgs.useGitIgnore,
		GitHubInstance:        execArgs.githubInstance,
		ContainerCapAdd:       execArgs.containerCapAdd,
		ContainerCapDrop:      execArgs.containerCapDrop,
		ContainerOptions:      execArgs.containerOptions,
		NoSkipCheckout:        execArgs.noSkipCheckout,
		// PresetGitHubContext:        preset,
		// EventJSON:                  string(eventJSON),
		ContainerNamePrefix:        fmt.Sprintf("FORGEJO-ACTIONS-TASK-%s", eventName),
		ContainerMaxLifetime:       maxLifetime,
		ContainerNetworkMode:       container.NetworkMode(execArgs.network),
		ContainerNetworkEnableIPv6: execArgs.enableIPv6,
		DefaultActionInstance:      execArgs.defaultActionsURL,
		PlatformPicker: func(_ []string) string {
			return execArgs.image
		},
		ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
	}

	config.Env["ACT_EXEC"] = "true"

	if t := config.Secrets["FORGEJO_TOKEN"]; t != "" {
		config.Token = t
	} else if t := config.Secrets["GITEA_TOKEN"]; t != "" {
		config.Token = t
	} else if t := config.Secrets["GITHUB_TOKEN"]; t != "" {
		config.Token = t
	}

	if !execArgs.debug {
		logLevel := log.InfoLevel
		config.JobLoggerLevel = &logLevel
	}

	r, err := runner.New(config)
	if err != nil {
		return err
	}

	ctx = common.WithDryrun(ctx, execArgs.dryrun)
	executor := r.NewPlanExecutor(plan)

	return executor(ctx)
}

func loadExecCmd(ctx context.Context) *cobra.Command {
	execArg := executeArgs{}

	execCmd := &cobra.Command{
		Use:   "exec",
		Short: "Run workflow locally.",
		Args:  cobra.MaximumNArgs(20),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runExec(ctx, &execArg)
		},
	}

	execCmd.Flags().BoolVarP(&execArg.runList, "list", "l", false, "list workflows")
	execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID")
	execCmd.Flags().StringVarP(&execArg.event, "event", "E", "", "run a event name")
	execCmd.PersistentFlags().StringVarP(&execArg.workflowsPath, "workflows", "W", "./.forgejo/workflows/", "path to workflow file(s)")
	execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "working directory")
	execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "Flag to disable running workflows from subdirectories of specified path in '--workflows'/'-W' flag")
	execCmd.Flags().BoolVarP(&execArg.autodetectEvent, "detect-event", "", false, "Use first event type from workflow as event that triggered the workflow")
	execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "pull docker image(s) even if already present")
	execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "rebuild local action docker image(s) even if already present")
	execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "Output logs in json format")
	execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv)")
	execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers")
	execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)")
	execCmd.Flags().StringArrayVarP(&execArg.vars, "var", "", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)")
	execCmd.PersistentFlags().BoolVarP(&execArg.insecureSecrets, "insecure-secrets", "", false, "NOT RECOMMENDED! Doesn't hide secrets while printing logs.")
	execCmd.Flags().BoolVar(&execArg.privileged, "privileged", false, "use privileged mode")
	execCmd.Flags().StringVar(&execArg.usernsMode, "userns", "", "user namespace to use")
	execCmd.PersistentFlags().StringVarP(&execArg.containerArchitecture, "container-architecture", "", "", "Architecture which should be used to run containers, e.g.: linux/amd64. If not specified, will use host default architecture. Requires Docker server API Version 1.41+. Ignored on earlier Docker server platforms.")
	execCmd.PersistentFlags().StringVarP(&execArg.containerDaemonSocket, "container-daemon-socket", "", "/var/run/docker.sock", "Please use the DOCKER_HOST environment variable as documented at https://forgejo.org/docs/next/admin/actions/runner-installation/#setting-up-the-container-environment instead.")
	execCmd.Flags().BoolVar(&execArg.useGitIgnore, "use-gitignore", true, "Controls whether paths specified in .gitignore should be copied into container")
	execCmd.Flags().StringArrayVarP(&execArg.containerCapAdd, "container-cap-add", "", []string{}, "kernel capabilities to add to the workflow containers (e.g. --container-cap-add SYS_PTRACE)")
	execCmd.Flags().StringArrayVarP(&execArg.containerCapDrop, "container-cap-drop", "", []string{}, "kernel capabilities to remove from the workflow containers (e.g. --container-cap-drop SYS_PTRACE)")
	execCmd.Flags().StringVarP(&execArg.containerOptions, "container-opts", "", "", "container options")
	execCmd.PersistentFlags().StringVarP(&execArg.defaultActionsURL, "default-actions-url", "", "https://code.forgejo.org", "Defines the default base url of the action.")
	execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
	execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log")
	execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
	execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "node:20-bullseye", "Docker image to use. Use \"-self-hosted\" to run directly on the host.")
	execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
	execCmd.PersistentFlags().BoolVarP(&execArg.enableIPv6, "enable-ipv6", "6", false, "Create network with IPv6 enabled.")
	execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "forgejo-instance", "", "", "Forgejo instance to use.")

	return execCmd
}
