/*
Copyright The Helm Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package engine

import (
	"errors"
	"fmt"
	"log/slog"
	"maps"
	"path"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"text/template"

	"k8s.io/client-go/rest"

	ci "helm.sh/helm/v4/pkg/chart"
	"helm.sh/helm/v4/pkg/chart/common"
)

// Engine is an implementation of the Helm rendering implementation for templates.
type Engine struct {
	// If strict is enabled, template rendering will fail if a template references
	// a value that was not passed in.
	Strict bool
	// In LintMode, some 'required' template values may be missing, so don't fail
	LintMode bool
	// optional provider of clients to talk to the Kubernetes API
	clientProvider *ClientProvider
	// EnableDNS tells the engine to allow DNS lookups when rendering templates
	EnableDNS bool
	// CustomTemplateFuncs is defined by users to provide custom template funcs
	CustomTemplateFuncs template.FuncMap
}

// New creates a new instance of Engine using the passed in rest config.
func New(config *rest.Config) Engine {
	var clientProvider ClientProvider = clientProviderFromConfig{config}
	return Engine{
		clientProvider: &clientProvider,
	}
}

// Render takes a chart, optional values, and value overrides, and attempts to render the Go templates.
//
// Render can be called repeatedly on the same engine.
//
// This will look in the chart's 'templates' data (e.g. the 'templates/' directory)
// and attempt to render the templates there using the values passed in.
//
// Values are scoped to their templates. A dependency template will not have
// access to the values set for its parent. If chart "foo" includes chart "bar",
// "bar" will not have access to the values for "foo".
//
// Values should be prepared with something like `chartutils.ReadValues`.
//
// Values are passed through the templates according to scope. If the top layer
// chart includes the chart foo, which includes the chart bar, the values map
// will be examined for a table called "foo". If "foo" is found in vals,
// that section of the values will be passed into the "foo" chart. And if that
// section contains a value named "bar", that value will be passed on to the
// bar chart during render time.
func (e Engine) Render(chrt ci.Charter, values common.Values) (map[string]string, error) {
	tmap := allTemplates(chrt, values)
	return e.render(tmap)
}

// Render takes a chart, optional values, and value overrides, and attempts to
// render the Go templates using the default options.
func Render(chrt ci.Charter, values common.Values) (map[string]string, error) {
	return new(Engine).Render(chrt, values)
}

// RenderWithClient takes a chart, optional values, and value overrides, and attempts to
// render the Go templates using the default options. This engine is client aware and so can have template
// functions that interact with the client.
func RenderWithClient(chrt ci.Charter, values common.Values, config *rest.Config) (map[string]string, error) {
	var clientProvider ClientProvider = clientProviderFromConfig{config}
	return Engine{
		clientProvider: &clientProvider,
	}.Render(chrt, values)
}

// RenderWithClientProvider takes a chart, optional values, and value overrides, and attempts to
// render the Go templates using the default options. This engine is client aware and so can have template
// functions that interact with the client.
// This function differs from RenderWithClient in that it lets you customize the way a dynamic client is constructed.
func RenderWithClientProvider(chrt ci.Charter, values common.Values, clientProvider ClientProvider) (map[string]string, error) {
	return Engine{
		clientProvider: &clientProvider,
	}.Render(chrt, values)
}

// renderable is an object that can be rendered.
type renderable struct {
	// tpl is the current template.
	tpl string
	// vals are the values to be supplied to the template.
	vals common.Values
	// namespace prefix to the templates of the current chart
	basePath string
}

const warnStartDelim = "HELM_ERR_START"
const warnEndDelim = "HELM_ERR_END"
const recursionMaxNums = 1000

var warnRegex = regexp.MustCompile(warnStartDelim + `((?s).*)` + warnEndDelim)

func warnWrap(warn string) string {
	return warnStartDelim + warn + warnEndDelim
}

// 'include' needs to be defined in the scope of a 'tpl' template as
// well as regular file-loaded templates.
func includeFun(t *template.Template, includedNames map[string]int) func(string, any) (string, error) {
	return func(name string, data any) (string, error) {
		var buf strings.Builder
		if v, ok := includedNames[name]; ok {
			if v > recursionMaxNums {
				return "", fmt.Errorf(
					"rendering template has a nested reference name: %s: %w",
					name, errors.New("unable to execute template"))
			}
			includedNames[name]++
		} else {
			includedNames[name] = 1
		}
		err := t.ExecuteTemplate(&buf, name, data)
		includedNames[name]--
		return buf.String(), err
	}
}

// As does 'tpl', so that nested calls to 'tpl' see the templates
// defined by their enclosing contexts.
func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, any) (string, error) {
	return func(tpl string, vals any) (string, error) {
		t, err := parent.Clone()
		if err != nil {
			return "", fmt.Errorf("cannot clone template: %w", err)
		}

		// Re-inject the missingkey option, see text/template issue https://github.com/golang/go/issues/43022
		// We have to go by strict from our engine configuration, as the option fields are private in Template.
		// TODO: Remove workaround (and the strict parameter) once we build only with golang versions with a fix.
		if strict {
			t.Option("missingkey=error")
		} else {
			t.Option("missingkey=zero")
		}

		// Re-inject 'include' so that it can close over our clone of t;
		// this lets any 'define's inside tpl be 'include'd.
		t.Funcs(template.FuncMap{
			"include": includeFun(t, includedNames),
			"tpl":     tplFun(t, includedNames, strict),
		})

		// We need a .New template, as template text which is just blanks
		// or comments after parsing out defines just adds new named
		// template definitions without changing the main template.
		// https://pkg.go.dev/text/template#Template.Parse
		// Use the parent's name for lack of a better way to identify the tpl
		// text string. (Maybe we could use a hash appended to the name?)
		t, err = t.New(parent.Name()).Parse(tpl)
		if err != nil {
			return "", fmt.Errorf("cannot parse template %q: %w", tpl, err)
		}

		var buf strings.Builder
		if err := t.Execute(&buf, vals); err != nil {
			return "", fmt.Errorf("error during tpl function execution for %q: %w", tpl, err)
		}

		// See comment in renderWithReferences explaining the <no value> hack.
		return strings.ReplaceAll(buf.String(), "<no value>", ""), nil
	}
}

// initFunMap creates the Engine's FuncMap and adds context-specific functions.
func (e Engine) initFunMap(t *template.Template) {
	funcMap := funcMap()
	includedNames := make(map[string]int)

	// Add the template-rendering functions here so we can close over t.
	funcMap["include"] = includeFun(t, includedNames)
	funcMap["tpl"] = tplFun(t, includedNames, e.Strict)

	// Add the `required` function here so we can use lintMode
	funcMap["required"] = func(warn string, val any) (any, error) {
		if val == nil {
			if e.LintMode {
				// Don't fail on missing required values when linting
				slog.Warn("missing required value", "message", warn)
				return "", nil
			}
			return val, errors.New(warnWrap(warn))
		} else if _, ok := val.(string); ok {
			if val == "" {
				if e.LintMode {
					// Don't fail on missing required values when linting
					slog.Warn("missing required values", "message", warn)
					return "", nil
				}
				return val, errors.New(warnWrap(warn))
			}
		}
		return val, nil
	}

	// Override sprig fail function for linting and wrapping message
	funcMap["fail"] = func(msg string) (string, error) {
		if e.LintMode {
			// Don't fail when linting
			slog.Info("funcMap fail", "message", msg)
			return "", nil
		}
		return "", errors.New(warnWrap(msg))
	}

	// If we are not linting and have a cluster connection, provide a Kubernetes-backed
	// implementation.
	if !e.LintMode && e.clientProvider != nil {
		funcMap["lookup"] = newLookupFunction(*e.clientProvider)
	}

	// When DNS lookups are not enabled override the sprig function and return
	// an empty string.
	if !e.EnableDNS {
		funcMap["getHostByName"] = func(_ string) string {
			return ""
		}
	}

	// Set custom template funcs
	maps.Copy(funcMap, e.CustomTemplateFuncs)

	t.Funcs(funcMap)
}

// render takes a map of templates/values and renders them.
func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, err error) {
	// Basically, what we do here is start with an empty parent template and then
	// build up a list of templates -- one for each file. Once all of the templates
	// have been parsed, we loop through again and execute every template.
	//
	// The idea with this process is to make it possible for more complex templates
	// to share common blocks, but to make the entire thing feel like a file-based
	// template engine.
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("rendering template failed: %v", r)
		}
	}()
	t := template.New("gotpl")
	if e.Strict {
		t.Option("missingkey=error")
	} else {
		// Not that zero will attempt to add default values for types it knows,
		// but will still emit <no value> for others. We mitigate that later.
		t.Option("missingkey=zero")
	}

	e.initFunMap(t)

	// We want to parse the templates in a predictable order. The order favors
	// higher-level (in file system) templates over deeply nested templates.
	keys := sortTemplates(tpls)

	for _, filename := range keys {
		r := tpls[filename]
		if _, err := t.New(filename).Parse(r.tpl); err != nil {
			return map[string]string{}, cleanupParseError(filename, err)
		}
	}

	rendered = make(map[string]string, len(keys))
	for _, filename := range keys {
		// Don't render partials. We don't care out the direct output of partials.
		// They are only included from other templates.
		if strings.HasPrefix(path.Base(filename), "_") {
			continue
		}
		// At render time, add information about the template that is being rendered.
		vals := tpls[filename].vals
		vals["Template"] = common.Values{"Name": filename, "BasePath": tpls[filename].basePath}
		var buf strings.Builder
		if err := t.ExecuteTemplate(&buf, filename, vals); err != nil {
			return map[string]string{}, reformatExecErrorMsg(filename, err)
		}

		// Work around the issue where Go will emit "<no value>" even if Options(missing=zero)
		// is set. Since missing=error will never get here, we do not need to handle
		// the Strict case.
		rendered[filename] = strings.ReplaceAll(buf.String(), "<no value>", "")
	}

	return rendered, nil
}

func cleanupParseError(filename string, err error) error {
	tokens := strings.Split(err.Error(), ": ")
	if len(tokens) == 1 {
		// This might happen if a non-templating error occurs
		return fmt.Errorf("parse error in (%s): %w", filename, err)
	}
	// The first token is "template"
	// The second token is either "filename:lineno" or "filename:lineNo:columnNo"
	location := tokens[1]
	// The remaining tokens make up a stacktrace-like chain, ending with the relevant error
	errMsg := tokens[len(tokens)-1]
	return fmt.Errorf("parse error at (%s): %s", location, errMsg)
}

type TraceableError struct {
	location         string
	message          string
	executedFunction string
}

func (t TraceableError) String() string {
	var errorString strings.Builder
	if t.location != "" {
		_, _ = fmt.Fprintf(&errorString, "%s\n  ", t.location)
	}
	if t.executedFunction != "" {
		_, _ = fmt.Fprintf(&errorString, "%s\n    ", t.executedFunction)
	}
	if t.message != "" {
		_, _ = fmt.Fprintf(&errorString, "%s\n", t.message)
	}
	return errorString.String()
}

// parseTemplateExecErrorString parses a template execution error string from text/template
// without using regular expressions. It returns a TraceableError and true if parsing succeeded.
func parseTemplateExecErrorString(s string) (TraceableError, bool) {
	const prefix = "template: "
	if !strings.HasPrefix(s, prefix) {
		return TraceableError{}, false
	}
	remainder := s[len(prefix):]

	// Special case: "template: no template %q associated with template %q"
	// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191
	traceableError, done := parseTemplateNoTemplateError(s, remainder)
	if done {
		return traceableError, true
	}

	// Executing form: "<templateName>: executing \"<funcName>\" at <<location>>: <errMsg>[ template:...]"
	// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141
	traceableError, done = parseTemplateExecutingAtErrorType(remainder)
	if done {
		return traceableError, true
	}

	// Simple form: "<templateName>: <errMsg>"
	// Use LastIndex to avoid splitting colons within line:col info.
	// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138
	traceableError, done = parseTemplateSimpleErrorString(remainder)
	if done {
		return traceableError, true
	}

	return TraceableError{}, false
}

// Special case: "template: no template %q associated with template %q"
// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191
func parseTemplateNoTemplateError(s string, remainder string) (TraceableError, bool) {
	if strings.HasPrefix(remainder, "no template ") {
		return TraceableError{message: s}, true
	}
	return TraceableError{}, false
}

// Simple form: "<templateName>: <errMsg>"
// Use LastIndex to avoid splitting colons within line:col info.
// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138
func parseTemplateSimpleErrorString(remainder string) (TraceableError, bool) {
	if sep := strings.LastIndex(remainder, ": "); sep != -1 {
		templateName := remainder[:sep]
		errMsg := remainder[sep+2:]
		if cut := strings.Index(errMsg, " template:"); cut != -1 {
			errMsg = errMsg[:cut]
		}
		return TraceableError{location: templateName, message: errMsg}, true
	}
	return TraceableError{}, false
}

// Executing form: "<templateName>: executing \"<funcName>\" at <<location>>: <errMsg>[ template:...]"
// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141
func parseTemplateExecutingAtErrorType(remainder string) (TraceableError, bool) {
	if templateName, after, found := strings.Cut(remainder, ": executing "); found {
		if len(after) == 0 || after[0] != '"' {
			return TraceableError{}, false
		}
		// find closing quote for function name
		endQuote := strings.IndexByte(after[1:], '"')
		if endQuote == -1 {
			return TraceableError{}, false
		}
		endQuote++ // account for offset we started at 1
		functionName := after[1:endQuote]
		afterFunc := after[endQuote+1:]

		// expect: " at <" then location then ">: " then message
		const atPrefix = " at <"
		if !strings.HasPrefix(afterFunc, atPrefix) {
			return TraceableError{}, false
		}
		afterAt := afterFunc[len(atPrefix):]
		locationName, errMsg, found := strings.Cut(afterAt, ">: ")
		if !found {
			return TraceableError{}, false
		}

		// trim chained next error starting with space + "template:" if present
		if cut := strings.Index(errMsg, " template:"); cut != -1 {
			errMsg = errMsg[:cut]
		}
		return TraceableError{
			location:         templateName,
			message:          errMsg,
			executedFunction: "executing \"" + functionName + "\" at <" + locationName + ">:",
		}, true
	}
	return TraceableError{}, false
}

// reformatExecErrorMsg takes an error message for template rendering and formats it into a formatted
// multi-line error string
func reformatExecErrorMsg(filename string, err error) error {
	// This function parses the error message produced by text/template package.
	// If it can parse out details from that error message such as the line number, template it failed on,
	// and error description, then it will construct a new error that displays these details in a structured way.
	// If there are issues with parsing the error message, the err passed into the function should return instead.
	var execError template.ExecError
	if !errors.As(err, &execError) {
		return err
	}

	tokens := strings.SplitN(err.Error(), ": ", 3)
	if len(tokens) != 3 {
		// This might happen if a non-templating error occurs
		return fmt.Errorf("execution error in (%s): %w", filename, err)
	}

	// The first token is "template"
	// The second token is either "filename:lineno" or "filename:lineNo:columnNo"
	location := tokens[1]

	parts := warnRegex.FindStringSubmatch(tokens[2])
	if len(parts) >= 2 {
		return fmt.Errorf("execution error at (%s): %s", location, parts[1])
	}
	current := err
	var fileLocations []TraceableError
	for current != nil {
		if tr, ok := parseTemplateExecErrorString(current.Error()); ok {
			if len(fileLocations) == 0 || fileLocations[len(fileLocations)-1] != tr {
				fileLocations = append(fileLocations, tr)
			}
		} else {
			return err
		}
		current = errors.Unwrap(current)
	}

	var finalErrorString strings.Builder
	for _, fileLocation := range fileLocations {
		_, _ = fmt.Fprintf(&finalErrorString, "%s", fileLocation.String())
	}

	return errors.New(strings.TrimSpace(finalErrorString.String()))
}

func sortTemplates(tpls map[string]renderable) []string {
	keys := make([]string, len(tpls))
	i := 0
	for key := range tpls {
		keys[i] = key
		i++
	}
	sort.Sort(sort.Reverse(byPathLen(keys)))
	return keys
}

type byPathLen []string

func (p byPathLen) Len() int      { return len(p) }
func (p byPathLen) Swap(i, j int) { p[j], p[i] = p[i], p[j] }
func (p byPathLen) Less(i, j int) bool {
	a, b := p[i], p[j]
	ca, cb := strings.Count(a, "/"), strings.Count(b, "/")
	if ca == cb {
		return strings.Compare(a, b) == -1
	}
	return ca < cb
}

// allTemplates returns all templates for a chart and its dependencies.
//
// As it goes, it also prepares the values in a scope-sensitive manner.
func allTemplates(c ci.Charter, vals common.Values) map[string]renderable {
	templates := make(map[string]renderable)
	recAllTpls(c, templates, vals)
	return templates
}

// recAllTpls recurses through the templates in a chart.
//
// As it recurses, it also sets the values to be appropriate for the template
// scope.
func recAllTpls(c ci.Charter, templates map[string]renderable, values common.Values) map[string]any {
	vals := values.AsMap()
	subCharts := make(map[string]any)
	accessor, err := ci.NewAccessor(c)
	if err != nil {
		slog.Error("error accessing chart", "error", err)
	}
	chartMetaData := accessor.MetadataAsMap()
	chartMetaData["IsRoot"] = accessor.IsRoot()

	next := map[string]any{
		"Chart":        chartMetaData,
		"Files":        newFiles(accessor.Files()),
		"Release":      vals["Release"],
		"Capabilities": vals["Capabilities"],
		"Values":       make(common.Values),
		"Subcharts":    subCharts,
	}

	// If there is a {{.Values.ThisChart}} in the parent metadata,
	// copy that into the {{.Values}} for this template.
	if accessor.IsRoot() {
		next["Values"] = vals["Values"]
	} else if vs, err := values.Table("Values." + accessor.Name()); err == nil {
		next["Values"] = vs
	}

	for _, child := range accessor.Dependencies() {
		// TODO: Handle error
		sub, _ := ci.NewAccessor(child)
		subCharts[sub.Name()] = recAllTpls(child, templates, next)
	}

	newParentID := accessor.ChartFullPath()
	for _, t := range accessor.Templates() {
		if t == nil {
			continue
		}
		if !isTemplateValid(accessor, t.Name) {
			continue
		}
		templates[path.Join(newParentID, t.Name)] = renderable{
			tpl:      string(t.Data),
			vals:     next,
			basePath: path.Join(newParentID, "templates"),
		}
	}

	return next
}

// isTemplateValid returns true if the template is valid for the chart type
func isTemplateValid(accessor ci.Accessor, templateName string) bool {
	if accessor.IsLibraryChart() {
		return strings.HasPrefix(filepath.Base(templateName), "_")
	}
	return true
}
