package chezmoi

import (
	"bytes"
	"encoding/json"
	"fmt"
	"maps"
	"regexp"
	"strconv"
	"strings"
	"text/template"

	"github.com/BurntSushi/toml"
	"github.com/goccy/go-yaml"
	"github.com/mitchellh/copystructure"
	"golang.org/x/text/encoding"
	"golang.org/x/text/encoding/unicode"
)

var formatIndentRx = regexp.MustCompile(`[ \t]*`)

// A Template extends [text/template.Template] with support for directives.
type Template struct {
	name     string
	template *template.Template
	options  TemplateOptions
}

// TemplateOptions are template options that can be set with directives.
type TemplateOptions struct {
	Encoding       encoding.Encoding
	Funcs          template.FuncMap
	FormatIndent   string
	LeftDelimiter  string
	LineEnding     string
	RightDelimiter string
	Options        []string
}

// ParseTemplate parses a template named name from data with the given funcs and
// templateOptions.
func ParseTemplate(name string, data []byte, options TemplateOptions) (*Template, error) {
	contents, err := options.parseAndRemoveDirectives(data)
	if err != nil {
		return nil, err
	}
	funcs := options.Funcs
	if options.FormatIndent != "" {
		if !formatIndentRx.MatchString(options.FormatIndent) {
			return nil, fmt.Errorf("%s: invalid format-indent", options.FormatIndent)
		}
		funcs = maps.Clone(funcs)
		funcs["toJson"] = func(data any) string {
			var builder strings.Builder
			encoder := json.NewEncoder(&builder)
			encoder.SetIndent("", options.FormatIndent)
			if err := encoder.Encode(data); err != nil {
				panic(err)
			}
			return builder.String()
		}
		funcs["toToml"] = func(data any) string {
			var builder strings.Builder
			encoder := toml.NewEncoder(&builder)
			encoder.Indent = options.FormatIndent
			if err := encoder.Encode(data); err != nil {
				panic(err)
			}
			return builder.String()
		}
		funcs["toYaml"] = func(data any) string {
			var builder strings.Builder
			encoder := yaml.NewEncoder(&builder,
				yaml.Indent(stringWidth(options.FormatIndent)),
			)
			if err := encoder.Encode(data); err != nil {
				panic(err)
			}
			return builder.String()
		}
	}
	tmpl, err := template.New(name).
		Option(options.Options...).
		Delims(options.LeftDelimiter, options.RightDelimiter).
		Funcs(funcs).
		Parse(string(contents))
	if err != nil {
		return nil, err
	}
	return &Template{
		name:     name,
		template: tmpl,
		options:  options,
	}, nil
}

// AddParseTree adds tmpl's parse tree to t.
func (t *Template) AddParseTree(tmpl *Template) (*Template, error) {
	var err error
	t.template, err = t.template.AddParseTree(tmpl.name, tmpl.template.Tree)
	return t, err
}

// Execute executes t with data.
func (t *Template) Execute(data any) ([]byte, error) {
	if data != nil {
		// Make a deep copy of data, in case any template functions modify it.
		var err error
		data, err = copystructure.Copy(data)
		if err != nil {
			return nil, err
		}
	}

	var builder strings.Builder
	if err := t.template.ExecuteTemplate(&builder, t.name, data); err != nil {
		return nil, err
	}

	result := []byte(replaceLineEndings(builder.String(), t.options.LineEnding))
	if t.options.Encoding != nil {
		return t.options.Encoding.NewEncoder().Bytes(result)
	}
	return result, nil
}

// ExecuteString executes t with s.
func (t *Template) ExecuteString(data any) (string, error) {
	resultBytes, err := t.Execute(data)
	if err != nil {
		return "", err
	}
	return string(resultBytes), err
}

// parseAndRemoveDirectives updates o by parsing all template directives in data
// and returns data with the lines containing directives removed. The lines are
// removed so that any delimiters do not break template parsing.
func (o *TemplateOptions) parseAndRemoveDirectives(data []byte) ([]byte, error) {
	directiveMatches := templateDirectiveRx.FindAllSubmatchIndex(data, -1)
	if directiveMatches == nil {
		return data, nil
	}

	// Parse options from directives.
	for _, directiveMatch := range directiveMatches {
		keyValuePairMatches := templateDirectiveKeyValuePairRx.FindAllSubmatch(data[directiveMatch[2]:directiveMatch[3]], -1)
		for _, keyValuePairMatch := range keyValuePairMatches {
			key := string(keyValuePairMatch[1])
			value := maybeUnquote(string(keyValuePairMatch[2]))
			switch key {
			case "encoding":
				switch value {
				case "utf-8":
					o.Encoding = unicode.UTF8
				case "utf-8-bom":
					o.Encoding = unicode.UTF8BOM
				case "utf-16-be":
					o.Encoding = unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM)
				case "utf-16-be-bom":
					o.Encoding = unicode.UTF16(unicode.BigEndian, unicode.UseBOM)
				case "utf-16-le":
					o.Encoding = unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM)
				case "utf-16-le-bom":
					o.Encoding = unicode.UTF16(unicode.LittleEndian, unicode.UseBOM)
				default:
					return nil, fmt.Errorf("%s: unknown encoding", value)
				}
			case "format-indent":
				o.FormatIndent = value
			case "format-indent-width":
				switch width, err := strconv.Atoi(value); {
				case err != nil:
					return nil, err
				case width < 0:
					return nil, fmt.Errorf("%d: invalid format-indent-width", width)
				default:
					o.FormatIndent = strings.Repeat(" ", width)
				}
			case "left-delimiter":
				o.LeftDelimiter = value
			case "line-ending", "line-endings":
				switch string(keyValuePairMatch[2]) {
				case "crlf":
					o.LineEnding = "\r\n"
				case "lf":
					o.LineEnding = "\n"
				case "native":
					o.LineEnding = nativeLineEnding
				default:
					o.LineEnding = value
				}
			case "right-delimiter":
				o.RightDelimiter = value
			case "missing-key":
				o.Options = append(o.Options, "missingkey="+value)
			}
		}
	}

	return removeMatches(data, directiveMatches), nil
}

// removeMatches returns data with matchesIndexes removed.
func removeMatches(data []byte, matchesIndexes [][]int) []byte {
	slices := make([][]byte, len(matchesIndexes)+1)
	slices[0] = data[:matchesIndexes[0][0]]
	for i, matchIndexes := range matchesIndexes[1:] {
		slices[i+1] = data[matchesIndexes[i][1]:matchIndexes[0]]
	}
	slices[len(matchesIndexes)] = data[matchesIndexes[len(matchesIndexes)-1][1]:]
	return bytes.Join(slices, nil)
}

// replaceLineEndings replaces all line endings in s with lineEnding. If
// lineEnding is empty it returns s unchanged.
func replaceLineEndings(s, lineEnding string) string {
	if lineEnding == "" {
		return s
	}
	return lineEndingRx.ReplaceAllString(s, lineEnding)
}

// stringWidth returns the width of s. s is assumed to contain only space and
// tab characters.
func stringWidth(s string) int {
	width := len(s)
	for _, b := range []byte(s) {
		if b == '\t' {
			width += 7
		}
	}
	return width
}
