package runner

import (
	"bytes"
	"context"
	"fmt"
	"path"
	"reflect"
	"regexp"
	"strings"
	"time"

	_ "embed"

	"github.com/nektos/act/pkg/common"
	"github.com/nektos/act/pkg/container"
	"github.com/nektos/act/pkg/exprparser"
	"github.com/nektos/act/pkg/model"
	"gopkg.in/yaml.v3"
)

// ExpressionEvaluator is the interface for evaluating expressions
type ExpressionEvaluator interface {
	evaluate(context.Context, string, exprparser.DefaultStatusCheck) (interface{}, error)
	EvaluateYamlNode(context.Context, *yaml.Node) error
	Interpolate(context.Context, string) string
}

// NewExpressionEvaluator creates a new evaluator
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) ExpressionEvaluator {
	return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
}

func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map[string]string) ExpressionEvaluator {
	var workflowCallResult map[string]*model.WorkflowCallResult

	// todo: cleanup EvaluationEnvironment creation
	using := make(map[string]exprparser.Needs)
	strategy := make(map[string]interface{})
	if rc.Run != nil {
		job := rc.Run.Job()
		if job != nil && job.Strategy != nil {
			strategy["fail-fast"] = job.Strategy.FailFast
			strategy["max-parallel"] = job.Strategy.MaxParallel
		}

		jobs := rc.Run.Workflow.Jobs
		jobNeeds := rc.Run.Job().Needs()

		for _, needs := range jobNeeds {
			using[needs] = exprparser.Needs{
				Outputs: jobs[needs].Outputs,
				Result:  jobs[needs].Result,
			}
		}

		// only setup jobs context in case of workflow_call
		// and existing expression evaluator (this means, jobs are at
		// least ready to run)
		if rc.caller != nil && rc.ExprEval != nil {
			workflowCallResult = map[string]*model.WorkflowCallResult{}

			for jobName, job := range jobs {
				result := model.WorkflowCallResult{
					Outputs: map[string]string{},
				}
				for k, v := range job.Outputs {
					result.Outputs[k] = v
				}
				workflowCallResult[jobName] = &result
			}
		}
	}

	ghc := rc.getGithubContext(ctx)
	inputs := getEvaluatorInputs(ctx, rc, nil, ghc)

	ee := &exprparser.EvaluationEnvironment{
		Github: ghc,
		Env:    env,
		Job:    rc.getJobContext(),
		Jobs:   &workflowCallResult,
		// todo: should be unavailable
		// but required to interpolate/evaluate the step outputs on the job
		Steps:     rc.getStepsContext(),
		Secrets:   getWorkflowSecrets(ctx, rc),
		Vars:      getWorkflowVars(ctx, rc),
		Strategy:  strategy,
		Matrix:    rc.Matrix,
		Needs:     using,
		Inputs:    inputs,
		HashFiles: getHashFilesFunction(ctx, rc),
	}
	if rc.JobContainer != nil {
		ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
	}
	return expressionEvaluator{
		interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
			Run:        rc.Run,
			WorkingDir: rc.Config.Workdir,
			Context:    "job",
		}),
	}
}

//go:embed hashfiles/index.js
var hashfiles string

// NewExpressionEvaluator creates a new evaluator
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) ExpressionEvaluator {
	// todo: cleanup EvaluationEnvironment creation
	job := rc.Run.Job()
	strategy := make(map[string]interface{})
	if job.Strategy != nil {
		strategy["fail-fast"] = job.Strategy.FailFast
		strategy["max-parallel"] = job.Strategy.MaxParallel
	}

	jobs := rc.Run.Workflow.Jobs
	jobNeeds := rc.Run.Job().Needs()

	using := make(map[string]exprparser.Needs)
	for _, needs := range jobNeeds {
		using[needs] = exprparser.Needs{
			Outputs: jobs[needs].Outputs,
			Result:  jobs[needs].Result,
		}
	}

	ghc := rc.getGithubContext(ctx)
	inputs := getEvaluatorInputs(ctx, rc, step, ghc)

	ee := &exprparser.EvaluationEnvironment{
		Github:   step.getGithubContext(ctx),
		Env:      *step.getEnv(),
		Job:      rc.getJobContext(),
		Steps:    rc.getStepsContext(),
		Secrets:  getWorkflowSecrets(ctx, rc),
		Vars:     getWorkflowVars(ctx, rc),
		Strategy: strategy,
		Matrix:   rc.Matrix,
		Needs:    using,
		// todo: should be unavailable
		// but required to interpolate/evaluate the inputs in actions/composite
		Inputs:    inputs,
		HashFiles: getHashFilesFunction(ctx, rc),
	}
	if rc.JobContainer != nil {
		ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
	}
	return expressionEvaluator{
		interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
			Run:        rc.Run,
			WorkingDir: rc.Config.Workdir,
			Context:    "step",
		}),
	}
}

func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.Value) (interface{}, error) {
	hashFiles := func(v []reflect.Value) (interface{}, error) {
		if rc.JobContainer != nil {
			timeed, cancel := context.WithTimeout(ctx, time.Minute)
			defer cancel()
			name := "workflow/hashfiles/index.js"
			hout := &bytes.Buffer{}
			herr := &bytes.Buffer{}
			patterns := []string{}
			followSymlink := false

			for i, p := range v {
				s := p.String()
				if i == 0 {
					if strings.HasPrefix(s, "--") {
						if strings.EqualFold(s, "--follow-symbolic-links") {
							followSymlink = true
							continue
						}
						return "", fmt.Errorf("Invalid glob option %s, available option: '--follow-symbolic-links'", s)
					}
				}
				patterns = append(patterns, s)
			}
			env := map[string]string{}
			for k, v := range rc.Env {
				env[k] = v
			}
			env["patterns"] = strings.Join(patterns, "\n")
			if followSymlink {
				env["followSymbolicLinks"] = "true"
			}

			stdout, stderr := rc.JobContainer.ReplaceLogWriter(hout, herr)
			_ = rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{
				Name: name,
				Mode: 0o644,
				Body: hashfiles,
			}).
				Then(rc.execJobContainer([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)},
					env, "", "")).
				Finally(func(context.Context) error {
					rc.JobContainer.ReplaceLogWriter(stdout, stderr)
					return nil
				})(timeed)
			output := hout.String() + "\n" + herr.String()
			guard := "__OUTPUT__"
			outstart := strings.Index(output, guard)
			if outstart != -1 {
				outstart += len(guard)
				outend := strings.Index(output[outstart:], guard)
				if outend != -1 {
					return output[outstart : outstart+outend], nil
				}
			}
		}
		return "", nil
	}
	return hashFiles
}

type expressionEvaluator struct {
	interpreter exprparser.Interpreter
}

func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (interface{}, error) {
	logger := common.Logger(ctx)
	logger.Debugf("evaluating expression '%s'", in)
	evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)

	printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%t", evaluated), "::add-mask::***)")
	logger.Debugf("expression '%s' evaluated to '%s'", in, printable)

	return evaluated, err
}

func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
	var in string
	if err := node.Decode(&in); err != nil {
		return nil, err
	}
	if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
		return nil, nil
	}
	expr, _ := rewriteSubExpression(ctx, in, false)
	res, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
	if err != nil {
		return nil, err
	}
	ret := &yaml.Node{}
	if err := ret.Encode(res); err != nil {
		return nil, err
	}
	return ret, err
}

func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
	var ret *yaml.Node
	// GitHub has this undocumented feature to merge maps, called insert directive
	insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
	for i := 0; i < len(node.Content)/2; i++ {
		changed := func() error {
			if ret == nil {
				ret = &yaml.Node{}
				if err := ret.Encode(node); err != nil {
					return err
				}
				ret.Content = ret.Content[:i*2]
			}
			return nil
		}
		k := node.Content[i*2]
		v := node.Content[i*2+1]
		ev, err := ee.evaluateYamlNodeInternal(ctx, v)
		if err != nil {
			return nil, err
		}
		if ev != nil {
			if err := changed(); err != nil {
				return nil, err
			}
		} else {
			ev = v
		}
		var sk string
		// Merge the nested map of the insert directive
		if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
			if ev.Kind != yaml.MappingNode {
				return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
			}
			if err := changed(); err != nil {
				return nil, err
			}
			ret.Content = append(ret.Content, ev.Content...)
		} else {
			ek, err := ee.evaluateYamlNodeInternal(ctx, k)
			if err != nil {
				return nil, err
			}
			if ek != nil {
				if err := changed(); err != nil {
					return nil, err
				}
			} else {
				ek = k
			}
			if ret != nil {
				ret.Content = append(ret.Content, ek, ev)
			}
		}
	}
	return ret, nil
}

func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
	var ret *yaml.Node
	for i := 0; i < len(node.Content); i++ {
		v := node.Content[i]
		// Preserve nested sequences
		wasseq := v.Kind == yaml.SequenceNode
		ev, err := ee.evaluateYamlNodeInternal(ctx, v)
		if err != nil {
			return nil, err
		}
		if ev != nil {
			if ret == nil {
				ret = &yaml.Node{}
				if err := ret.Encode(node); err != nil {
					return nil, err
				}
				ret.Content = ret.Content[:i]
			}
			// GitHub has this undocumented feature to merge sequences / arrays
			// We have a nested sequence via evaluation, merge the arrays
			if ev.Kind == yaml.SequenceNode && !wasseq {
				ret.Content = append(ret.Content, ev.Content...)
			} else {
				ret.Content = append(ret.Content, ev)
			}
		} else if ret != nil {
			ret.Content = append(ret.Content, v)
		}
	}
	return ret, nil
}

func (ee expressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
	switch node.Kind {
	case yaml.ScalarNode:
		return ee.evaluateScalarYamlNode(ctx, node)
	case yaml.MappingNode:
		return ee.evaluateMappingYamlNode(ctx, node)
	case yaml.SequenceNode:
		return ee.evaluateSequenceYamlNode(ctx, node)
	default:
		return nil, nil
	}
}

func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
	ret, err := ee.evaluateYamlNodeInternal(ctx, node)
	if err != nil {
		return err
	}
	if ret != nil {
		return ret.Decode(node)
	}
	return nil
}

func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
	if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
		return in
	}

	expr, _ := rewriteSubExpression(ctx, in, true)
	evaluated, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
	if err != nil {
		common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", expr, err)
		return ""
	}

	value, ok := evaluated.(string)
	if !ok {
		panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
	}

	return value
}

// EvalBool evaluates an expression against given evaluator
func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
	nextExpr, _ := rewriteSubExpression(ctx, expr, false)

	evaluated, err := evaluator.evaluate(ctx, nextExpr, defaultStatusCheck)
	if err != nil {
		return false, err
	}

	return exprparser.IsTruthy(evaluated), nil
}

func escapeFormatString(in string) string {
	return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
}

//nolint:gocyclo
func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) {
	if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
		return in, nil
	}

	strPattern := regexp.MustCompile("(?:''|[^'])*'")
	pos := 0
	exprStart := -1
	strStart := -1
	var results []string
	formatOut := ""
	for pos < len(in) {
		if strStart > -1 {
			matches := strPattern.FindStringIndex(in[pos:])
			if matches == nil {
				panic("unclosed string.")
			}

			strStart = -1
			pos += matches[1]
		} else if exprStart > -1 {
			exprEnd := strings.Index(in[pos:], "}}")
			strStart = strings.Index(in[pos:], "'")

			if exprEnd > -1 && strStart > -1 {
				if exprEnd < strStart {
					strStart = -1
				} else {
					exprEnd = -1
				}
			}

			if exprEnd > -1 {
				formatOut += fmt.Sprintf("{%d}", len(results))
				results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
				pos += exprEnd + 2
				exprStart = -1
			} else if strStart > -1 {
				pos += strStart + 1
			} else {
				panic("unclosed expression.")
			}
		} else {
			exprStart = strings.Index(in[pos:], "${{")
			if exprStart != -1 {
				formatOut += escapeFormatString(in[pos : pos+exprStart])
				exprStart = pos + exprStart + 3
				pos = exprStart
			} else {
				formatOut += escapeFormatString(in[pos:])
				pos = len(in)
			}
		}
	}

	if len(results) == 1 && formatOut == "{0}" && !forceFormat {
		return in, nil
	}

	out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", "))
	if in != out {
		common.Logger(ctx).Debugf("expression '%s' rewritten to '%s'", in, out)
	}
	return out, nil
}

//nolint:gocyclo
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]interface{} {
	inputs := map[string]interface{}{}

	setupWorkflowInputs(ctx, &inputs, rc)

	var env map[string]string
	if step != nil {
		env = *step.getEnv()
	} else {
		env = rc.GetEnv()
	}

	for k, v := range env {
		if strings.HasPrefix(k, "INPUT_") {
			inputs[strings.ToLower(strings.TrimPrefix(k, "INPUT_"))] = v
		}
	}

	if ghc.EventName == "workflow_dispatch" {
		config := rc.Run.Workflow.WorkflowDispatchConfig()
		if config != nil && config.Inputs != nil {
			for k, v := range config.Inputs {
				value := nestedMapLookup(ghc.Event, "inputs", k)
				if value == nil {
					value = v.Default
				}
				if v.Type == "boolean" {
					inputs[k] = value == "true"
				} else {
					inputs[k] = value
				}
			}
		}
	}

	if ghc.EventName == "workflow_call" {
		config := rc.Run.Workflow.WorkflowCallConfig()
		if config != nil && config.Inputs != nil {
			for k, v := range config.Inputs {
				value := nestedMapLookup(ghc.Event, "inputs", k)
				if value == nil {
					value = v.Default
				}
				if v.Type == "boolean" {
					inputs[k] = value == "true"
				} else {
					inputs[k] = value
				}
			}
		}
	}
	return inputs
}

func setupWorkflowInputs(ctx context.Context, inputs *map[string]interface{}, rc *RunContext) {
	if rc.caller != nil {
		config := rc.Run.Workflow.WorkflowCallConfig()

		for name, input := range config.Inputs {
			value := rc.caller.runContext.Run.Job().With[name]
			if value != nil {
				if str, ok := value.(string); ok {
					// evaluate using the calling RunContext (outside)
					value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
				}
			}

			if value == nil && config != nil && config.Inputs != nil {
				value = input.Default
				if rc.ExprEval != nil {
					if str, ok := value.(string); ok {
						// evaluate using the called RunContext (inside)
						value = rc.ExprEval.Interpolate(ctx, str)
					}
				}
			}

			(*inputs)[name] = value
		}
	}
}

func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
	if rc.caller != nil {
		job := rc.caller.runContext.Run.Job()
		secrets := job.Secrets()

		if secrets == nil && job.InheritSecrets() {
			secrets = rc.caller.runContext.Config.Secrets
		}

		if secrets == nil {
			secrets = map[string]string{}
		}

		for k, v := range secrets {
			secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
		}

		return secrets
	}

	return rc.Config.Secrets
}

func getWorkflowVars(_ context.Context, rc *RunContext) map[string]string {
	return rc.Config.Vars
}