package runner

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"runtime"
	"strings"

	"github.com/nektos/act/pkg/container"

	"github.com/nektos/act/pkg/common"
	"github.com/nektos/act/pkg/model"
	log "github.com/sirupsen/logrus"
)

// RunContext contains info about current job
type RunContext struct {
	Name         string
	Config       *Config
	Matrix       map[string]interface{}
	Run          *model.Run
	EventJSON    string
	Env          map[string]string
	ExtraPath    []string
	CurrentStep  string
	StepResults  map[string]*stepResult
	ExprEval     ExpressionEvaluator
	JobContainer container.Container
}

func (rc *RunContext) String() string {
	return fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name)
}

type stepResult struct {
	Success bool              `json:"success"`
	Outputs map[string]string `json:"outputs"`
}

// GetEnv returns the env for the context
func (rc *RunContext) GetEnv() map[string]string {
	if rc.Env == nil {
		rc.Env = mergeMaps(rc.Config.Env, rc.Run.Workflow.Env, rc.Run.Job().Env)
	}
	rc.Env["ACT"] = "true"
	return rc.Env
}

func (rc *RunContext) jobContainerName() string {
	return createContainerName("act", rc.String())
}

func (rc *RunContext) startJobContainer() common.Executor {
	image := rc.platformImage()

	return func(ctx context.Context) error {
		rawLogger := common.Logger(ctx).WithField("raw_output", true)
		logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
			if rc.Config.LogOutput {
				rawLogger.Infof("%s", s)
			} else {
				rawLogger.Debugf("%s", s)
			}
			return true
		})

		common.Logger(ctx).Infof("\U0001f680  Start image=%s", image)
		name := rc.jobContainerName()

		envList := make([]string, 0)
		bindModifiers := ""
		if runtime.GOOS == "darwin" {
			bindModifiers = ":delegated"
		}

		envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
		envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
		envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))

		binds := []string{
			fmt.Sprintf("%s:%s", "/var/run/docker.sock", "/var/run/docker.sock"),
		}
		if rc.Config.BindWorkdir {
			binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, "/github/workspace", bindModifiers))
		}

		rc.JobContainer = container.NewContainer(&container.NewContainerInput{
			Cmd:        nil,
			Entrypoint: []string{"/usr/bin/tail", "-f", "/dev/null"},
			WorkingDir: "/github/workspace",
			Image:      image,
			Name:       name,
			Env:        envList,
			Mounts: map[string]string{
				name:            "/github",
				"act-toolcache": "/toolcache",
				"act-actions":   "/actions",
			},
			NetworkMode: "host",
			Binds:       binds,
			Stdout:      logWriter,
			Stderr:      logWriter,
			Privileged:  rc.Config.Privileged,
			UsernsMode:  rc.Config.UsernsMode,
		})

		var copyWorkspace bool
		var copyToPath string
		if !rc.Config.BindWorkdir {
			copyToPath, copyWorkspace = rc.localCheckoutPath()
			copyToPath = filepath.Join("/github/workspace", copyToPath)
		}

		return common.NewPipelineExecutor(
			rc.JobContainer.Pull(rc.Config.ForcePull),
			rc.stopJobContainer(),
			rc.JobContainer.Create(),
			rc.JobContainer.Start(false),
			rc.JobContainer.CopyDir(copyToPath, rc.Config.Workdir+string(filepath.Separator)+".").IfBool(copyWorkspace),
			rc.JobContainer.Copy("/github/", &container.FileEntry{
				Name: "workflow/event.json",
				Mode: 0644,
				Body: rc.EventJSON,
			}, &container.FileEntry{
				Name: "workflow/envs.txt",
				Mode: 0644,
				Body: "",
			}, &container.FileEntry{
				Name: "home/.act",
				Mode: 0644,
				Body: "",
			}),
		)(ctx)
	}
}
func (rc *RunContext) execJobContainer(cmd []string, env map[string]string) common.Executor {
	return func(ctx context.Context) error {
		return rc.JobContainer.Exec(cmd, env)(ctx)
	}
}

// stopJobContainer removes the job container (if it exists) and its volume (if it exists) if !rc.Config.ReuseContainers
func (rc *RunContext) stopJobContainer() common.Executor {
	return func(ctx context.Context) error {
		if rc.JobContainer != nil && !rc.Config.ReuseContainers {
			return rc.JobContainer.Remove().
				Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName(), false))(ctx)
		}
		return nil
	}
}

// ActionCacheDir is for rc
func (rc *RunContext) ActionCacheDir() string {
	var xdgCache string
	var ok bool
	if xdgCache, ok = os.LookupEnv("XDG_CACHE_HOME"); !ok {
		if home, ok := os.LookupEnv("HOME"); ok {
			xdgCache = fmt.Sprintf("%s/.cache", home)
		}
	}
	return filepath.Join(xdgCache, "act")
}

// Executor returns a pipeline executor for all the steps in the job
func (rc *RunContext) Executor() common.Executor {
	steps := make([]common.Executor, 0)

	steps = append(steps, func(ctx context.Context) error {
		if len(rc.Matrix) > 0 {
			common.Logger(ctx).Infof("\U0001F9EA  Matrix: %v", rc.Matrix)
		}
		return nil
	})

	steps = append(steps, rc.startJobContainer())

	for i, step := range rc.Run.Job().Steps {
		if step.ID == "" {
			step.ID = fmt.Sprintf("%d", i)
		}
		steps = append(steps, rc.newStepExecutor(step))
	}
	steps = append(steps, rc.stopJobContainer())

	return common.NewPipelineExecutor(steps...).If(rc.isEnabled)
}

func (rc *RunContext) newStepExecutor(step *model.Step) common.Executor {
	sc := &StepContext{
		RunContext: rc,
		Step:       step,
	}
	return func(ctx context.Context) error {
		rc.CurrentStep = sc.Step.ID
		rc.StepResults[rc.CurrentStep] = &stepResult{
			Success: true,
			Outputs: make(map[string]string),
		}

		exprEval, err := sc.setupEnv(ctx)
		if err != nil {
			return err
		}
		rc.ExprEval = exprEval

		runStep, err := rc.EvalBool(sc.Step.If)
		if err != nil {
			common.Logger(ctx).Errorf("  \u274C  Error in if: expression - %s", sc.Step)
			rc.StepResults[rc.CurrentStep].Success = false
			return err
		}
		if !runStep {
			log.Debugf("Skipping step '%s' due to '%s'", sc.Step.String(), sc.Step.If)
			return nil
		}

		common.Logger(ctx).Infof("\u2B50  Run %s", sc.Step)
		err = sc.Executor()(ctx)
		if err == nil {
			common.Logger(ctx).Infof("  \u2705  Success - %s", sc.Step)
		} else {
			common.Logger(ctx).Errorf("  \u274C  Failure - %s", sc.Step)

			if sc.Step.ContinueOnError {
				common.Logger(ctx).Infof("Failed but continue next step")
				err = nil
				rc.StepResults[rc.CurrentStep].Success = true
			} else {
				rc.StepResults[rc.CurrentStep].Success = false
			}
		}
		return err
	}
}

func (rc *RunContext) platformImage() string {
	job := rc.Run.Job()

	c := job.Container()
	if c != nil {
		return rc.ExprEval.Interpolate(c.Image)
	}

	if job.RunsOn() == nil {
		log.Errorf("'runs-on' key not defined in %s", rc.String())
	}

	for _, runnerLabel := range job.RunsOn() {
		platformName := rc.ExprEval.Interpolate(runnerLabel)
		image := rc.Config.Platforms[strings.ToLower(platformName)]
		if image != "" {
			return image
		}
	}

	return ""
}

func (rc *RunContext) isEnabled(ctx context.Context) bool {
	job := rc.Run.Job()
	l := common.Logger(ctx)
	runJob, err := rc.EvalBool(job.If)
	if err != nil {
		common.Logger(ctx).Errorf("  \u274C  Error in if: expression - %s", job.Name)
		return false
	}
	if !runJob {
		l.Debugf("Skipping job '%s' due to '%s'", job.Name, job.If)
		return false
	}

	img := rc.platformImage()
	if img == "" {
		if job.RunsOn() == nil {
			log.Errorf("'runs-on' key not defined in %s", rc.String())
		}

		for _, runnerLabel := range job.RunsOn() {
			platformName := rc.ExprEval.Interpolate(runnerLabel)
			l.Infof("\U0001F6A7  Skipping unsupported platform '%+v'", platformName)
		}
		return false
	}
	return true
}

var splitPattern *regexp.Regexp

// EvalBool evaluates an expression against current run context
func (rc *RunContext) EvalBool(expr string) (bool, error) {
	if splitPattern == nil {
		splitPattern = regexp.MustCompile(fmt.Sprintf(`%s|%s|\S+`, expressionPattern.String(), operatorPattern.String()))
	}
	if strings.HasPrefix(strings.TrimSpace(expr), "!") {
		return false, errors.New("expressions starting with ! must be wrapped in ${{ }}")
	}
	if expr != "" {
		parts := splitPattern.FindAllString(expr, -1)
		var evaluatedParts []string
		for i, part := range parts {
			if operatorPattern.MatchString(part) {
				evaluatedParts = append(evaluatedParts, part)
				continue
			}

			interpolatedPart, isString := rc.ExprEval.InterpolateWithStringCheck(part)

			// This peculiar transformation has to be done because the Github parser
			// treats false returned from contexts as a string, not a boolean.
			// Hence env.SOMETHING will be evaluated to true in an if: expression
			// regardless if SOMETHING is set to false, true or any other string.
			// It also handles some other weirdness that I found by trial and error.
			if (expressionPattern.MatchString(part) && // it is an expression
				!strings.Contains(part, "!")) && // but it's not negated
				interpolatedPart == "false" && // and the interpolated string is false
				(isString || previousOrNextPartIsAnOperator(i, parts)) { // and it's of type string or has an logical operator before or after

				interpolatedPart = fmt.Sprintf("'%s'", interpolatedPart) // then we have to quote the false expression
			}

			evaluatedParts = append(evaluatedParts, interpolatedPart)
		}

		joined := strings.Join(evaluatedParts, " ")
		v, _, err := rc.ExprEval.Evaluate(fmt.Sprintf("Boolean(%s)", joined))
		if err != nil {
			return false, err
		}
		log.Debugf("expression '%s' evaluated to '%s'", expr, v)
		return v == "true", nil
	}
	return true, nil
}

func previousOrNextPartIsAnOperator(i int, parts []string) bool {
	operator := false
	if i > 0 {
		operator = operatorPattern.MatchString(parts[i-1])
	}
	if i+1 < len(parts) {
		operator = operator || operatorPattern.MatchString(parts[i+1])
	}
	return operator
}

func mergeMaps(maps ...map[string]string) map[string]string {
	rtnMap := make(map[string]string)
	for _, m := range maps {
		for k, v := range m {
			rtnMap[k] = v
		}
	}
	return rtnMap
}

func createContainerName(parts ...string) string {
	name := make([]string, 0)
	pattern := regexp.MustCompile("[^a-zA-Z0-9]")
	partLen := (30 / len(parts)) - 1
	for i, part := range parts {
		if i == len(parts)-1 {
			name = append(name, pattern.ReplaceAllString(part, "-"))
		} else {
			name = append(name, trimToLen(pattern.ReplaceAllString(part, "-"), partLen))
		}
	}
	return strings.Trim(strings.Join(name, "-"), "-")
}

func trimToLen(s string, l int) string {
	if l < 0 {
		l = 0
	}
	if len(s) > l {
		return s[:l]
	}
	return s
}

type jobContext struct {
	Status    string `json:"status"`
	Container struct {
		ID      string `json:"id"`
		Network string `json:"network"`
	} `json:"container"`
	Services map[string]struct {
		ID string `json:"id"`
	} `json:"services"`
}

func (rc *RunContext) getJobContext() *jobContext {
	jobStatus := "success"
	for _, stepStatus := range rc.StepResults {
		if !stepStatus.Success {
			jobStatus = "failure"
			break
		}
	}
	return &jobContext{
		Status: jobStatus,
	}
}

func (rc *RunContext) getStepsContext() map[string]*stepResult {
	return rc.StepResults
}

type githubContext struct {
	Event      map[string]interface{} `json:"event"`
	EventPath  string                 `json:"event_path"`
	Workflow   string                 `json:"workflow"`
	RunID      string                 `json:"run_id"`
	RunNumber  string                 `json:"run_number"`
	Actor      string                 `json:"actor"`
	Repository string                 `json:"repository"`
	EventName  string                 `json:"event_name"`
	Sha        string                 `json:"sha"`
	Ref        string                 `json:"ref"`
	HeadRef    string                 `json:"head_ref"`
	BaseRef    string                 `json:"base_ref"`
	Token      string                 `json:"token"`
	Workspace  string                 `json:"workspace"`
	Action     string                 `json:"action"`
}

func (rc *RunContext) getGithubContext() *githubContext {
	token, ok := rc.Config.Secrets["GITHUB_TOKEN"]
	if !ok {
		token = os.Getenv("GITHUB_TOKEN")
	}
	runID := rc.Config.Env["GITHUB_RUN_ID"]
	if runID == "" {
		runID = "1"
	}
	runNumber := rc.Config.Env["GITHUB_RUN_NUMBER"]
	if runNumber == "" {
		runNumber = "1"
	}
	ghc := &githubContext{
		Event:     make(map[string]interface{}),
		EventPath: "/github/workflow/event.json",
		Workflow:  rc.Run.Workflow.Name,
		RunID:     runID,
		RunNumber: runNumber,
		Actor:     rc.Config.Actor,
		EventName: rc.Config.EventName,
		Token:     token,
		Workspace: "/github/workspace",
		Action:    rc.CurrentStep,
	}

	// Backwards compatibility for configs that require
	// a default rather than being run as a cmd
	if ghc.Actor == "" {
		ghc.Actor = "nektos/act"
	}

	repoPath := rc.Config.Workdir
	repo, err := common.FindGithubRepo(repoPath)
	if err != nil {
		log.Warningf("unable to get git repo: %v", err)
	} else {
		ghc.Repository = repo
	}

	_, sha, err := common.FindGitRevision(repoPath)
	if err != nil {
		log.Warningf("unable to get git revision: %v", err)
	} else {
		ghc.Sha = sha
	}

	ref, err := common.FindGitRef(repoPath)
	if err != nil {
		log.Warningf("unable to get git ref: %v", err)
	} else {
		log.Debugf("using github ref: %s", ref)
		ghc.Ref = ref
	}
	if rc.EventJSON != "" {
		err = json.Unmarshal([]byte(rc.EventJSON), &ghc.Event)
		if err != nil {
			log.Errorf("Unable to Unmarshal event '%s': %v", rc.EventJSON, err)
		}
	}

	// set the branch in the event data
	if rc.Config.DefaultBranch != "" {
		ghc.Event = withDefaultBranch(rc.Config.DefaultBranch, ghc.Event)
	} else {
		ghc.Event = withDefaultBranch("master", ghc.Event)
	}

	if ghc.EventName == "pull_request" {
		ghc.BaseRef = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "ref"))
		ghc.HeadRef = asString(nestedMapLookup(ghc.Event, "pull_request", "head", "ref"))
	}

	return ghc
}

func (ghc *githubContext) isLocalCheckout(step *model.Step) bool {
	if step.Type() != model.StepTypeUsesActionRemote {
		return false
	}
	remoteAction := newRemoteAction(step.Uses)
	if !remoteAction.IsCheckout() {
		return false
	}

	if repository, ok := step.With["repository"]; ok && repository != ghc.Repository {
		return false
	}
	if repository, ok := step.With["ref"]; ok && repository != ghc.Ref {
		return false
	}
	return true
}

func asString(v interface{}) string {
	if v == nil {
		return ""
	} else if s, ok := v.(string); ok {
		return s
	}
	return ""
}

func nestedMapLookup(m map[string]interface{}, ks ...string) (rval interface{}) {
	var ok bool

	if len(ks) == 0 { // degenerate input
		return nil
	}
	if rval, ok = m[ks[0]]; !ok {
		return nil
	} else if len(ks) == 1 { // we've reached the final key
		return rval
	} else if m, ok = rval.(map[string]interface{}); !ok {
		return nil
	} else { // 1+ more keys
		return nestedMapLookup(m, ks[1:]...)
	}
}

func withDefaultBranch(b string, event map[string]interface{}) map[string]interface{} {
	repoI, ok := event["repository"]
	if !ok {
		repoI = make(map[string]interface{})
	}

	repo, ok := repoI.(map[string]interface{})
	if !ok {
		log.Warnf("unable to set default branch to %v", b)
		return event
	}

	// if the branch is already there return with no changes
	if _, ok = repo["default_branch"]; ok {
		return event
	}

	repo["default_branch"] = b
	event["repository"] = repo

	return event
}

func (rc *RunContext) withGithubEnv(env map[string]string) map[string]string {
	github := rc.getGithubContext()
	env["CI"] = "true"
	env["HOME"] = "/github/home"
	env["GITHUB_ENV"] = "/github/workflow/envs.txt"

	env["GITHUB_WORKFLOW"] = github.Workflow
	env["GITHUB_RUN_ID"] = github.RunID
	env["GITHUB_RUN_NUMBER"] = github.RunNumber
	env["GITHUB_ACTION"] = github.Action
	env["GITHUB_ACTIONS"] = "true"
	env["GITHUB_ACTOR"] = github.Actor
	env["GITHUB_REPOSITORY"] = github.Repository
	env["GITHUB_EVENT_NAME"] = github.EventName
	env["GITHUB_EVENT_PATH"] = github.EventPath
	env["GITHUB_WORKSPACE"] = github.Workspace
	env["GITHUB_SHA"] = github.Sha
	env["GITHUB_REF"] = github.Ref
	env["GITHUB_TOKEN"] = github.Token
	env["GITHUB_SERVER_URL"] = "https://github.com"
	env["GITHUB_API_URL"] = "https://api.github.com"
	env["GITHUB_GRAPHQL_URL"] = "https://api.github.com/graphql"
	return env
}

func (rc *RunContext) localCheckoutPath() (string, bool) {
	ghContext := rc.getGithubContext()
	for _, step := range rc.Run.Job().Steps {
		if ghContext.isLocalCheckout(step) {
			return step.With["path"], true
		}
	}
	return "", false
}