package model

import (
	"fmt"
	"io"
	"reflect"
	"regexp"
	"strconv"
	"strings"

	"github.com/nektos/act/pkg/common"
	log "github.com/sirupsen/logrus"
	"gopkg.in/yaml.v3"
)

// Workflow is the structure of the files in .github/workflows
type Workflow struct {
	File     string
	Name     string            `yaml:"name"`
	RawOn    yaml.Node         `yaml:"on"`
	Env      map[string]string `yaml:"env"`
	Jobs     map[string]*Job   `yaml:"jobs"`
	Defaults Defaults          `yaml:"defaults"`
}

// On events for the workflow
func (w *Workflow) On() []string {
	switch w.RawOn.Kind {
	case yaml.ScalarNode:
		var val string
		err := w.RawOn.Decode(&val)
		if err != nil {
			log.Fatal(err)
		}
		return []string{val}
	case yaml.SequenceNode:
		var val []string
		err := w.RawOn.Decode(&val)
		if err != nil {
			log.Fatal(err)
		}
		return val
	case yaml.MappingNode:
		var val map[string]interface{}
		err := w.RawOn.Decode(&val)
		if err != nil {
			log.Fatal(err)
		}
		var keys []string
		for k := range val {
			keys = append(keys, k)
		}
		return keys
	}
	return nil
}

func (w *Workflow) OnEvent(event string) interface{} {
	if w.RawOn.Kind == yaml.MappingNode {
		var val map[string]interface{}
		if !decodeNode(w.RawOn, &val) {
			return nil
		}
		return val[event]
	}
	return nil
}

type WorkflowDispatchInput struct {
	Description string   `yaml:"description"`
	Required    bool     `yaml:"required"`
	Default     string   `yaml:"default"`
	Type        string   `yaml:"type"`
	Options     []string `yaml:"options"`
}

type WorkflowDispatch struct {
	Inputs map[string]WorkflowDispatchInput `yaml:"inputs"`
}

func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch {
	switch w.RawOn.Kind {
	case yaml.ScalarNode:
		var val string
		if !decodeNode(w.RawOn, &val) {
			return nil
		}
		if val == "workflow_dispatch" {
			return &WorkflowDispatch{}
		}
	case yaml.SequenceNode:
		var val []string
		if !decodeNode(w.RawOn, &val) {
			return nil
		}
		for _, v := range val {
			if v == "workflow_dispatch" {
				return &WorkflowDispatch{}
			}
		}
	case yaml.MappingNode:
		var val map[string]yaml.Node
		if !decodeNode(w.RawOn, &val) {
			return nil
		}

		n, found := val["workflow_dispatch"]
		var workflowDispatch WorkflowDispatch
		if found && decodeNode(n, &workflowDispatch) {
			return &workflowDispatch
		}
	default:
		return nil
	}
	return nil
}

type WorkflowCallInput struct {
	Description string `yaml:"description"`
	Required    bool   `yaml:"required"`
	Default     string `yaml:"default"`
	Type        string `yaml:"type"`
}

type WorkflowCallOutput struct {
	Description string `yaml:"description"`
	Value       string `yaml:"value"`
}

type WorkflowCall struct {
	Inputs  map[string]WorkflowCallInput  `yaml:"inputs"`
	Outputs map[string]WorkflowCallOutput `yaml:"outputs"`
}

type WorkflowCallResult struct {
	Outputs map[string]string
}

func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
	if w.RawOn.Kind != yaml.MappingNode {
		// The callers expect for "on: workflow_call" and "on: [ workflow_call ]" a non nil return value
		return &WorkflowCall{}
	}

	var val map[string]yaml.Node
	if !decodeNode(w.RawOn, &val) {
		return &WorkflowCall{}
	}

	var config WorkflowCall
	node := val["workflow_call"]
	if !decodeNode(node, &config) {
		return &WorkflowCall{}
	}

	return &config
}

// Job is the structure of one job in a workflow
type Job struct {
	Name           string                    `yaml:"name"`
	RawNeeds       yaml.Node                 `yaml:"needs"`
	RawRunsOn      yaml.Node                 `yaml:"runs-on"`
	Env            yaml.Node                 `yaml:"env"`
	If             yaml.Node                 `yaml:"if"`
	Steps          []*Step                   `yaml:"steps"`
	TimeoutMinutes string                    `yaml:"timeout-minutes"`
	Services       map[string]*ContainerSpec `yaml:"services"`
	Strategy       *Strategy                 `yaml:"strategy"`
	RawContainer   yaml.Node                 `yaml:"container"`
	Defaults       Defaults                  `yaml:"defaults"`
	Outputs        map[string]string         `yaml:"outputs"`
	Uses           string                    `yaml:"uses"`
	With           map[string]interface{}    `yaml:"with"`
	RawSecrets     yaml.Node                 `yaml:"secrets"`
	Result         string
}

// Strategy for the job
type Strategy struct {
	FailFast          bool
	MaxParallel       int
	FailFastString    string    `yaml:"fail-fast"`
	MaxParallelString string    `yaml:"max-parallel"`
	RawMatrix         yaml.Node `yaml:"matrix"`
}

// Default settings that will apply to all steps in the job or workflow
type Defaults struct {
	Run RunDefaults `yaml:"run"`
}

// Defaults for all run steps in the job or workflow
type RunDefaults struct {
	Shell            string `yaml:"shell"`
	WorkingDirectory string `yaml:"working-directory"`
}

// GetMaxParallel sets default and returns value for `max-parallel`
func (s Strategy) GetMaxParallel() int {
	// MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines`
	// So I take the liberty to hardcode default limit to 4 and this is because:
	// 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735
	// 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45
	// 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO
	maxParallel := 4
	if s.MaxParallelString != "" {
		var err error
		if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil {
			log.Errorf("Failed to parse 'max-parallel' option: %v", err)
		}
	}
	return maxParallel
}

// GetFailFast sets default and returns value for `fail-fast`
func (s Strategy) GetFailFast() bool {
	// FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107
	failFast := true
	log.Debug(s.FailFastString)
	if s.FailFastString != "" {
		var err error
		if failFast, err = strconv.ParseBool(s.FailFastString); err != nil {
			log.Errorf("Failed to parse 'fail-fast' option: %v", err)
		}
	}
	return failFast
}

func (j *Job) InheritSecrets() bool {
	if j.RawSecrets.Kind != yaml.ScalarNode {
		return false
	}

	var val string
	if !decodeNode(j.RawSecrets, &val) {
		return false
	}

	return val == "inherit"
}

func (j *Job) Secrets() map[string]string {
	if j.RawSecrets.Kind != yaml.MappingNode {
		return nil
	}

	var val map[string]string
	if !decodeNode(j.RawSecrets, &val) {
		return nil
	}

	return val
}

// Container details for the job
func (j *Job) Container() *ContainerSpec {
	var val *ContainerSpec
	switch j.RawContainer.Kind {
	case yaml.ScalarNode:
		val = new(ContainerSpec)
		if !decodeNode(j.RawContainer, &val.Image) {
			return nil
		}
	case yaml.MappingNode:
		val = new(ContainerSpec)
		if !decodeNode(j.RawContainer, val) {
			return nil
		}
	}
	return val
}

// Needs list for Job
func (j *Job) Needs() []string {
	switch j.RawNeeds.Kind {
	case yaml.ScalarNode:
		var val string
		if !decodeNode(j.RawNeeds, &val) {
			return nil
		}
		return []string{val}
	case yaml.SequenceNode:
		var val []string
		if !decodeNode(j.RawNeeds, &val) {
			return nil
		}
		return val
	}
	return nil
}

// RunsOn list for Job
func (j *Job) RunsOn() []string {
	switch j.RawRunsOn.Kind {
	case yaml.MappingNode:
		var val struct {
			Group  string
			Labels yaml.Node
		}

		if !decodeNode(j.RawRunsOn, &val) {
			return nil
		}

		labels := nodeAsStringSlice(val.Labels)

		if val.Group != "" {
			labels = append(labels, val.Group)
		}

		return labels
	default:
		return nodeAsStringSlice(j.RawRunsOn)
	}
}

func nodeAsStringSlice(node yaml.Node) []string {
	switch node.Kind {
	case yaml.ScalarNode:
		var val string
		if !decodeNode(node, &val) {
			return nil
		}
		return []string{val}
	case yaml.SequenceNode:
		var val []string
		if !decodeNode(node, &val) {
			return nil
		}
		return val
	}
	return nil
}

func environment(yml yaml.Node) map[string]string {
	env := make(map[string]string)
	if yml.Kind == yaml.MappingNode {
		if !decodeNode(yml, &env) {
			return nil
		}
	}
	return env
}

// Environments returns string-based key=value map for a job
func (j *Job) Environment() map[string]string {
	return environment(j.Env)
}

// Matrix decodes RawMatrix YAML node
func (j *Job) Matrix() map[string][]interface{} {
	if j.Strategy.RawMatrix.Kind == yaml.MappingNode {
		var val map[string][]interface{}
		if !decodeNode(j.Strategy.RawMatrix, &val) {
			return nil
		}
		return val
	}
	return nil
}

// GetMatrixes returns the matrix cross product
// It skips includes and hard fails excludes for non-existing keys
//
//nolint:gocyclo
func (j *Job) GetMatrixes() ([]map[string]interface{}, error) {
	matrixes := make([]map[string]interface{}, 0)
	if j.Strategy != nil {
		j.Strategy.FailFast = j.Strategy.GetFailFast()
		j.Strategy.MaxParallel = j.Strategy.GetMaxParallel()

		if m := j.Matrix(); m != nil {
			includes := make([]map[string]interface{}, 0)
			extraIncludes := make([]map[string]interface{}, 0)
			for _, v := range m["include"] {
				switch t := v.(type) {
				case []interface{}:
					for _, i := range t {
						i := i.(map[string]interface{})
						extraInclude := true
						for k := range i {
							if _, ok := m[k]; ok {
								includes = append(includes, i)
								extraInclude = false
								break
							}
						}
						if extraInclude {
							extraIncludes = append(extraIncludes, i)
						}
					}
				case interface{}:
					v := v.(map[string]interface{})
					extraInclude := true
					for k := range v {
						if _, ok := m[k]; ok {
							includes = append(includes, v)
							extraInclude = false
							break
						}
					}
					if extraInclude {
						extraIncludes = append(extraIncludes, v)
					}
				}
			}
			delete(m, "include")

			excludes := make([]map[string]interface{}, 0)
			for _, e := range m["exclude"] {
				e := e.(map[string]interface{})
				for k := range e {
					if _, ok := m[k]; ok {
						excludes = append(excludes, e)
					} else {
						// We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include
						return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k)
					}
				}
			}
			delete(m, "exclude")

			matrixProduct := common.CartesianProduct(m)
		MATRIX:
			for _, matrix := range matrixProduct {
				for _, exclude := range excludes {
					if commonKeysMatch(matrix, exclude) {
						log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude)
						continue MATRIX
					}
				}
				matrixes = append(matrixes, matrix)
			}
			for _, include := range includes {
				matched := false
				for _, matrix := range matrixes {
					if commonKeysMatch2(matrix, include, m) {
						matched = true
						log.Debugf("Adding include values '%v' to existing entry", include)
						for k, v := range include {
							matrix[k] = v
						}
					}
				}
				if !matched {
					extraIncludes = append(extraIncludes, include)
				}
			}
			for _, include := range extraIncludes {
				log.Debugf("Adding include '%v'", include)
				matrixes = append(matrixes, include)
			}
			if len(matrixes) == 0 {
				matrixes = append(matrixes, make(map[string]interface{}))
			}
		} else {
			matrixes = append(matrixes, make(map[string]interface{}))
		}
	} else {
		matrixes = append(matrixes, make(map[string]interface{}))
		log.Debugf("Empty Strategy, matrixes=%v", matrixes)
	}
	return matrixes, nil
}

func commonKeysMatch(a map[string]interface{}, b map[string]interface{}) bool {
	for aKey, aVal := range a {
		if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
			return false
		}
	}
	return true
}

func commonKeysMatch2(a map[string]interface{}, b map[string]interface{}, m map[string][]interface{}) bool {
	for aKey, aVal := range a {
		_, useKey := m[aKey]
		if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {
			return false
		}
	}
	return true
}

// JobType describes what type of job we are about to run
type JobType int

const (
	// JobTypeDefault is all jobs that have a `run` attribute
	JobTypeDefault JobType = iota

	// JobTypeReusableWorkflowLocal is all jobs that have a `uses` that is a local workflow in the .github/workflows directory
	JobTypeReusableWorkflowLocal

	// JobTypeReusableWorkflowRemote is all jobs that have a `uses` that references a workflow file in a github repo
	JobTypeReusableWorkflowRemote

	// JobTypeInvalid represents a job which is not configured correctly
	JobTypeInvalid
)

func (j JobType) String() string {
	switch j {
	case JobTypeDefault:
		return "default"
	case JobTypeReusableWorkflowLocal:
		return "local-reusable-workflow"
	case JobTypeReusableWorkflowRemote:
		return "remote-reusable-workflow"
	}
	return "unknown"
}

// Type returns the type of the job
func (j *Job) Type() (JobType, error) {
	isReusable := j.Uses != ""

	if isReusable {
		isYaml, _ := regexp.MatchString(`\.(ya?ml)(?:$|@)`, j.Uses)

		if isYaml {
			isLocalPath := strings.HasPrefix(j.Uses, "./")
			isRemotePath, _ := regexp.MatchString(`^[^.](.+?/){2,}.+\.ya?ml@`, j.Uses)
			hasVersion, _ := regexp.MatchString(`\.ya?ml@`, j.Uses)

			if isLocalPath {
				return JobTypeReusableWorkflowLocal, nil
			} else if isRemotePath && hasVersion {
				return JobTypeReusableWorkflowRemote, nil
			}
		}

		return JobTypeInvalid, fmt.Errorf("`uses` key references invalid workflow path '%s'. Must start with './' if it's a local workflow, or must start with '<org>/<repo>/' and include an '@' if it's a remote workflow", j.Uses)
	}

	return JobTypeDefault, nil
}

// ContainerSpec is the specification of the container to use for the job
type ContainerSpec struct {
	Image       string            `yaml:"image"`
	Env         map[string]string `yaml:"env"`
	Ports       []string          `yaml:"ports"`
	Volumes     []string          `yaml:"volumes"`
	Options     string            `yaml:"options"`
	Credentials map[string]string `yaml:"credentials"`
	Entrypoint  string
	Args        string
	Name        string
	Reuse       bool
}

// Step is the structure of one step in a job
type Step struct {
	ID                 string            `yaml:"id"`
	If                 yaml.Node         `yaml:"if"`
	Name               string            `yaml:"name"`
	Uses               string            `yaml:"uses"`
	Run                string            `yaml:"run"`
	WorkingDirectory   string            `yaml:"working-directory"`
	Shell              string            `yaml:"shell"`
	Env                yaml.Node         `yaml:"env"`
	With               map[string]string `yaml:"with"`
	RawContinueOnError string            `yaml:"continue-on-error"`
	TimeoutMinutes     string            `yaml:"timeout-minutes"`
}

// String gets the name of step
func (s *Step) String() string {
	if s.Name != "" {
		return s.Name
	} else if s.Uses != "" {
		return s.Uses
	} else if s.Run != "" {
		return s.Run
	}
	return s.ID
}

// Environments returns string-based key=value map for a step
func (s *Step) Environment() map[string]string {
	return environment(s.Env)
}

// GetEnv gets the env for a step
func (s *Step) GetEnv() map[string]string {
	env := s.Environment()

	for k, v := range s.With {
		envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(k), "_")
		envKey = fmt.Sprintf("INPUT_%s", strings.ToUpper(envKey))
		env[envKey] = v
	}
	return env
}

// ShellCommand returns the command for the shell
func (s *Step) ShellCommand() string {
	shellCommand := ""

	//Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17
	switch s.Shell {
	case "", "bash":
		shellCommand = "bash --noprofile --norc -e -o pipefail {0}"
	case "pwsh":
		shellCommand = "pwsh -command . '{0}'"
	case "python":
		shellCommand = "python {0}"
	case "sh":
		shellCommand = "sh -e {0}"
	case "cmd":
		shellCommand = "cmd /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\""
	case "powershell":
		shellCommand = "powershell -command . '{0}'"
	default:
		shellCommand = s.Shell
	}
	return shellCommand
}

// StepType describes what type of step we are about to run
type StepType int

const (
	// StepTypeRun is all steps that have a `run` attribute
	StepTypeRun StepType = iota

	// StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...`
	StepTypeUsesDockerURL

	// StepTypeUsesActionLocal is all steps that have a `uses` that is a local action in a subdirectory
	StepTypeUsesActionLocal

	// StepTypeUsesActionRemote is all steps that have a `uses` that is a reference to a github repo
	StepTypeUsesActionRemote

	// StepTypeReusableWorkflowLocal is all steps that have a `uses` that is a local workflow in the .github/workflows directory
	StepTypeReusableWorkflowLocal

	// StepTypeReusableWorkflowRemote is all steps that have a `uses` that references a workflow file in a github repo
	StepTypeReusableWorkflowRemote

	// StepTypeInvalid is for steps that have invalid step action
	StepTypeInvalid
)

func (s StepType) String() string {
	switch s {
	case StepTypeInvalid:
		return "invalid"
	case StepTypeRun:
		return "run"
	case StepTypeUsesActionLocal:
		return "local-action"
	case StepTypeUsesActionRemote:
		return "remote-action"
	case StepTypeUsesDockerURL:
		return "docker"
	case StepTypeReusableWorkflowLocal:
		return "local-reusable-workflow"
	case StepTypeReusableWorkflowRemote:
		return "remote-reusable-workflow"
	}
	return "unknown"
}

// Type returns the type of the step
func (s *Step) Type() StepType {
	if s.Run == "" && s.Uses == "" {
		return StepTypeInvalid
	}

	if s.Run != "" {
		if s.Uses != "" {
			return StepTypeInvalid
		}
		return StepTypeRun
	} else if strings.HasPrefix(s.Uses, "docker://") {
		return StepTypeUsesDockerURL
	} else if strings.HasPrefix(s.Uses, "./.github/workflows") && (strings.HasSuffix(s.Uses, ".yml") || strings.HasSuffix(s.Uses, ".yaml")) {
		return StepTypeReusableWorkflowLocal
	} else if !strings.HasPrefix(s.Uses, "./") && strings.Contains(s.Uses, ".github/workflows") && (strings.Contains(s.Uses, ".yml@") || strings.Contains(s.Uses, ".yaml@")) {
		return StepTypeReusableWorkflowRemote
	} else if strings.HasPrefix(s.Uses, "./") {
		return StepTypeUsesActionLocal
	}
	return StepTypeUsesActionRemote
}

// ReadWorkflow returns a list of jobs for a given workflow file reader
func ReadWorkflow(in io.Reader) (*Workflow, error) {
	w := new(Workflow)
	err := yaml.NewDecoder(in).Decode(w)
	return w, err
}

// GetJob will get a job by name in the workflow
func (w *Workflow) GetJob(jobID string) *Job {
	for id, j := range w.Jobs {
		if jobID == id {
			if j.Name == "" {
				j.Name = id
			}
			if j.If.Value == "" {
				j.If.Value = "success()"
			}
			return j
		}
	}
	return nil
}

// GetJobIDs will get all the job names in the workflow
func (w *Workflow) GetJobIDs() []string {
	ids := make([]string, 0)
	for id := range w.Jobs {
		ids = append(ids, id)
	}
	return ids
}

var OnDecodeNodeError = func(node yaml.Node, out interface{}, err error) {
	log.Fatalf("Failed to decode node %v into %T: %v", node, out, err)
}

func decodeNode(node yaml.Node, out interface{}) bool {
	if err := node.Decode(out); err != nil {
		if OnDecodeNodeError != nil {
			OnDecodeNodeError(node, out, err)
		}
		return false
	}
	return true
}
