// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package jobspec2

import (
	"fmt"
	"strings"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/ext/dynblock"
	"github.com/hashicorp/nomad/api"
	"github.com/hashicorp/nomad/jobspec2/hclutil"
	"github.com/zclconf/go-cty/cty"
)

const (
	variablesLabel = "variables"
	variableLabel  = "variable"
	localsLabel    = "locals"
	vaultLabel     = "vault"
	taskLabel      = "task"

	inputVariablesAccessor = "var"
	localsAccessor         = "local"
)

type jobConfig struct {
	JobID string `hcl:",label"`
	Job   *api.Job

	ParseConfig *ParseConfig

	Vault *api.Vault  `hcl:"vault,block"`
	Tasks []*api.Task `hcl:"task,block"`

	InputVariables Variables
	LocalVariables Variables

	LocalBlocks []*LocalBlock
}

func newJobConfig(parseConfig *ParseConfig) *jobConfig {
	return &jobConfig{
		ParseConfig: parseConfig,

		InputVariables: Variables{},
		LocalVariables: Variables{},
	}
}

var jobConfigSchema = &hcl.BodySchema{
	Blocks: []hcl.BlockHeaderSchema{
		{Type: variablesLabel},
		{Type: variableLabel, LabelNames: []string{"name"}},
		{Type: localsLabel},
		{Type: "job", LabelNames: []string{"name"}},
	},
}

func (c *jobConfig) decodeBody(body hcl.Body) hcl.Diagnostics {
	content, diags := body.Content(jobConfigSchema)
	if len(diags) != 0 {
		return diags
	}

	diags = append(diags, c.decodeInputVariables(content)...)
	diags = append(diags, c.parseLocalVariables(content)...)
	diags = append(diags, c.collectInputVariableValues(c.ParseConfig.Envs, c.ParseConfig.parsedVarFiles, toVars(c.ParseConfig.ArgVars))...)

	_, moreDiags := c.InputVariables.Values()
	diags = append(diags, moreDiags...)
	_, moreDiags = c.LocalVariables.Values()
	diags = append(diags, moreDiags...)
	diags = append(diags, c.evaluateLocalVariables(c.LocalBlocks)...)

	// Errors at this point are likely syntax errors which can result in
	// invalid state when we try to decode the rest of the job. If we continue
	// we may panic and that will obscure the error, so return early so the
	// user can be told how to fix their jobspec.
	if diags.HasErrors() {
		return diags
	}
	nctx := c.EvalContext()

	diags = append(diags, c.decodeJob(content, nctx)...)
	return diags
}

// decodeInputVariables looks in the found blocks for 'variables' and
// 'variable' blocks. It should be called firsthand so that other blocks can
// use the variables.
func (c *jobConfig) decodeInputVariables(content *hcl.BodyContent) hcl.Diagnostics {
	var diags hcl.Diagnostics

	for _, block := range content.Blocks {
		switch block.Type {
		case variableLabel:
			moreDiags := c.InputVariables.decodeVariableBlock(block, nil)
			diags = append(diags, moreDiags...)
		case variablesLabel:
			attrs, moreDiags := block.Body.JustAttributes()
			diags = append(diags, moreDiags...)
			for key, attr := range attrs {
				moreDiags = c.InputVariables.decodeVariable(key, attr, nil)
				diags = append(diags, moreDiags...)
			}
		}
	}
	return diags
}

// parseLocalVariables looks in the found blocks for 'locals' blocks. It
// should be called after parsing input variables so that they can be
// referenced.
func (c *jobConfig) parseLocalVariables(content *hcl.BodyContent) hcl.Diagnostics {
	var diags hcl.Diagnostics

	for _, block := range content.Blocks {
		switch block.Type {
		case localsLabel:
			attrs, moreDiags := block.Body.JustAttributes()
			diags = append(diags, moreDiags...)
			for name, attr := range attrs {
				if _, found := c.LocalVariables[name]; found {
					diags = append(diags, &hcl.Diagnostic{
						Severity: hcl.DiagError,
						Summary:  "Duplicate value in " + localsLabel,
						Detail:   "Duplicate " + name + " definition found.",
						Subject:  attr.NameRange.Ptr(),
						Context:  block.DefRange.Ptr(),
					})
					return diags
				}
				c.LocalBlocks = append(c.LocalBlocks, &LocalBlock{
					Name: name,
					Expr: attr.Expr,
				})
			}
		}
	}

	return diags
}

func (c *jobConfig) decodeTopLevelExtras(content *hcl.BodyContent, ctx *hcl.EvalContext) hcl.Diagnostics {
	var diags hcl.Diagnostics

	var foundVault *hcl.Block
	for _, b := range content.Blocks {
		if b.Type == vaultLabel {
			if foundVault != nil {
				diags = append(diags, &hcl.Diagnostic{
					Severity: hcl.DiagError,
					Summary:  fmt.Sprintf("Duplicate %s block", b.Type),
					Detail: fmt.Sprintf(
						"Only one block of type %q is allowed. Previous definition was at %s.",
						b.Type, foundVault.DefRange.String(),
					),
					Subject: &b.DefRange,
				})
				continue
			}
			foundVault = b

			v := &api.Vault{}
			diags = append(diags, hclDecoder.DecodeBody(b.Body, ctx, v)...)
			c.Vault = v

		} else if b.Type == taskLabel {
			t := &api.Task{}
			diags = append(diags, hclDecoder.DecodeBody(b.Body, ctx, t)...)
			if len(b.Labels) == 1 {
				t.Name = b.Labels[0]
				c.Tasks = append(c.Tasks, t)
			}
		}
	}

	return diags
}

func (c *jobConfig) evaluateLocalVariables(locals []*LocalBlock) hcl.Diagnostics {
	var diags hcl.Diagnostics

	if len(locals) > 0 && c.LocalVariables == nil {
		c.LocalVariables = Variables{}
	}

	var retry, previousL int
	for len(locals) > 0 {
		local := locals[0]
		moreDiags := c.evaluateLocalVariable(local)
		if moreDiags.HasErrors() {
			if len(locals) == 1 {
				// If this is the only local left there's no need
				// to try evaluating again
				return append(diags, moreDiags...)
			}
			if previousL == len(locals) {
				if retry == 100 {
					// To get to this point, locals must have a circle dependency
					return append(diags, moreDiags...)
				}
				retry++
			}
			previousL = len(locals)

			// If local uses another local that has not been evaluated yet this could be the reason of errors
			// Push local to the end of slice to be evaluated later
			locals = append(locals, local)
		} else {
			retry = 0
			diags = append(diags, moreDiags...)
		}
		// Remove local from slice
		locals = append(locals[:0], locals[1:]...)
	}

	return diags
}

func (c *jobConfig) evaluateLocalVariable(local *LocalBlock) hcl.Diagnostics {
	var diags hcl.Diagnostics

	value, moreDiags := local.Expr.Value(c.EvalContext())
	diags = append(diags, moreDiags...)
	if moreDiags.HasErrors() {
		return diags
	}
	c.LocalVariables[local.Name] = &Variable{
		Name: local.Name,
		Values: []VariableAssignment{{
			Value: value,
			Expr:  local.Expr,
			From:  "default",
		}},
		Type: value.Type(),
	}

	return diags
}

func (c *jobConfig) decodeJob(content *hcl.BodyContent, ctx *hcl.EvalContext) hcl.Diagnostics {
	var diags hcl.Diagnostics

	c.Job = &api.Job{}

	var found *hcl.Block
	for _, b := range content.Blocks {
		if b.Type != "job" {
			continue
		}

		body := hclutil.BlocksAsAttrs(b.Body)
		body = dynblock.Expand(body, ctx)

		if found != nil {
			diags = append(diags, &hcl.Diagnostic{
				Severity: hcl.DiagError,
				Summary:  fmt.Sprintf("Duplicate %s block", b.Type),
				Detail: fmt.Sprintf(
					"Only one block of type %q is allowed. Previous definition was at %s.",
					b.Type, found.DefRange.String(),
				),
				Subject: &b.DefRange,
			})
			continue
		}
		found = b

		c.JobID = b.Labels[0]

		metaAttr, body, mdiags := decodeAsAttribute(body, ctx, "meta")
		diags = append(diags, mdiags...)

		extra, remain, mdiags := body.PartialContent(&hcl.BodySchema{
			Blocks: []hcl.BlockHeaderSchema{
				{Type: "vault"},
				{Type: "task", LabelNames: []string{"name"}},
			},
		})

		diags = append(diags, mdiags...)
		diags = append(diags, c.decodeTopLevelExtras(extra, ctx)...)
		diags = append(diags, hclDecoder.DecodeBody(remain, ctx, c.Job)...)

		if metaAttr != nil {
			c.Job.Meta = metaAttr
		}
	}

	if found == nil {
		diags = append(diags, &hcl.Diagnostic{
			Severity: hcl.DiagError,
			Summary:  "Missing job block",
			Detail:   "A job block is required",
		})
	}

	return diags

}

func (c *jobConfig) EvalContext() *hcl.EvalContext {
	vars, _ := c.InputVariables.Values()
	locals, _ := c.LocalVariables.Values()
	return &hcl.EvalContext{
		Functions: Functions(c.ParseConfig.BaseDir, c.ParseConfig.AllowFS),
		Variables: map[string]cty.Value{
			inputVariablesAccessor: cty.ObjectVal(vars),
			localsAccessor:         cty.ObjectVal(locals),
		},
		UndefinedVariable: func(t hcl.Traversal) (cty.Value, hcl.Diagnostics) {
			body := c.ParseConfig.Body
			start := t.SourceRange().Start.Byte
			end := t.SourceRange().End.Byte

			v := string(body[start:end])

			// find the start of inclusing "${..}" if it's inclused in one; otherwise, wrap it in one
			isBracketed := false
			for i := start - 1; i >= 1; i-- {
				if body[i] == '{' && body[i-1] == '$' {
					isBracketed = true
					v = string(body[i-1:start]) + v
					break
				} else if body[i] != ' ' {
					break
				}
			}

			if isBracketed {
				for i := end + 1; i < len(body); i++ {
					if body[i] == '}' {
						v += string(body[end:i])
					} else if body[i] != ' ' {
						// unexpected!
						v += "}"
						break
					}
				}

			} else {
				v = "${" + v + "}"
			}

			return cty.StringVal(v), nil
		},
	}
}

func toVars(vars []string) map[string]string {
	attrs := make(map[string]string, len(vars))
	for _, arg := range vars {
		parts := strings.SplitN(arg, "=", 2)
		if len(parts) == 2 {
			attrs[parts[0]] = parts[1]
		}
	}

	return attrs
}
