// Package template facilitates processing of JSON strings using Go templates.
// Provides additional functions not available using basic Go templates, such as coloring,
// and table rendering.
package template

import (
	"encoding/json"
	"fmt"
	"io"
	"math"
	"strconv"
	"strings"
	"text/template"
	"time"

	"github.com/cli/go-gh/v2/pkg/tableprinter"
	"github.com/cli/go-gh/v2/pkg/text"
	color "github.com/mgutz/ansi"
)

const (
	ellipsis = "..."
)

// Template is the representation of a template.
type Template struct {
	colorEnabled bool
	output       io.Writer
	tmpl         *template.Template
	tp           tableprinter.TablePrinter
	width        int
	funcs        template.FuncMap
}

// New initializes a Template.
func New(w io.Writer, width int, colorEnabled bool) *Template {
	return &Template{
		colorEnabled: colorEnabled,
		output:       w,
		tp:           tableprinter.New(w, true, width),
		width:        width,
		funcs:        template.FuncMap{},
	}
}

// Funcs adds the elements of the argument map to the template's function map.
// It must be called before the template is parsed.
// It is legal to overwrite elements of the map including default functions.
// The return value is the template, so calls can be chained.
func (t *Template) Funcs(funcMap map[string]interface{}) *Template {
	for name, f := range funcMap {
		t.funcs[name] = f
	}
	return t
}

// Parse the given template string for use with Execute.
func (t *Template) Parse(tmpl string) error {
	now := time.Now()
	templateFuncs := map[string]interface{}{
		"autocolor": colorFunc,
		"color":     colorFunc,
		"hyperlink": hyperlinkFunc,
		"join":      joinFunc,
		"pluck":     pluckFunc,
		"tablerender": func() (string, error) {
			// After rendering a table, prepare a new table printer incase user wants to output
			// another table.
			defer func() {
				t.tp = tableprinter.New(t.output, true, t.width)
			}()
			return tableRenderFunc(t.tp)
		},
		"tablerow": func(fields ...interface{}) (string, error) {
			return tableRowFunc(t.tp, fields...)
		},
		"timeago": func(input string) (string, error) {
			return timeAgoFunc(now, input)
		},
		"timefmt":  timeFormatFunc,
		"truncate": truncateFunc,
	}
	if !t.colorEnabled {
		templateFuncs["autocolor"] = autoColorFunc
	}
	for name, f := range t.funcs {
		templateFuncs[name] = f
	}
	var err error
	t.tmpl, err = template.New("").Funcs(templateFuncs).Parse(tmpl)
	return err
}

// Execute applies the parsed template to the input and writes result to the writer
// the template was initialized with.
func (t *Template) Execute(input io.Reader) error {
	jsonData, err := io.ReadAll(input)
	if err != nil {
		return err
	}

	var data interface{}
	if err := json.Unmarshal(jsonData, &data); err != nil {
		return err
	}

	return t.tmpl.Execute(t.output, data)
}

// Flush writes any remaining data to the writer. This is mostly useful
// when a templates uses the tablerow function but does not include the
// tablerender function at the end.
// If a template did not use the table functionality this is a noop.
func (t *Template) Flush() error {
	if _, err := tableRenderFunc(t.tp); err != nil {
		return err
	}
	return nil
}

func colorFunc(colorName string, input interface{}) (string, error) {
	text, err := jsonScalarToString(input)
	if err != nil {
		return "", err
	}
	return color.Color(text, colorName), nil
}

func pluckFunc(field string, input []interface{}) []interface{} {
	var results []interface{}
	for _, item := range input {
		obj := item.(map[string]interface{})
		results = append(results, obj[field])
	}
	return results
}

func joinFunc(sep string, input []interface{}) (string, error) {
	var results []string
	for _, item := range input {
		text, err := jsonScalarToString(item)
		if err != nil {
			return "", err
		}
		results = append(results, text)
	}
	return strings.Join(results, sep), nil
}

func timeFormatFunc(format, input string) (string, error) {
	t, err := time.Parse(time.RFC3339, input)
	if err != nil {
		return "", err
	}
	return t.Format(format), nil
}

func timeAgoFunc(now time.Time, input string) (string, error) {
	t, err := time.Parse(time.RFC3339, input)
	if err != nil {
		return "", err
	}
	return timeAgo(now.Sub(t)), nil
}

func truncateFunc(maxWidth int, v interface{}) (string, error) {
	if v == nil {
		return "", nil
	}
	if s, ok := v.(string); ok {
		return text.Truncate(maxWidth, s), nil
	}
	return "", fmt.Errorf("invalid value; expected string, got %T", v)
}

func autoColorFunc(colorName string, input interface{}) (string, error) {
	return jsonScalarToString(input)
}

func tableRowFunc(tp tableprinter.TablePrinter, fields ...interface{}) (string, error) {
	if tp == nil {
		return "", fmt.Errorf("failed to write table row: no table printer")
	}
	for _, e := range fields {
		s, err := jsonScalarToString(e)
		if err != nil {
			return "", fmt.Errorf("failed to write table row: %v", err)
		}
		tp.AddField(s, tableprinter.WithTruncate(truncateMultiline))
	}
	tp.EndRow()
	return "", nil
}

func tableRenderFunc(tp tableprinter.TablePrinter) (string, error) {
	if tp == nil {
		return "", fmt.Errorf("failed to render table: no table printer")
	}
	err := tp.Render()
	if err != nil {
		return "", fmt.Errorf("failed to render table: %v", err)
	}
	return "", nil
}

func jsonScalarToString(input interface{}) (string, error) {
	switch tt := input.(type) {
	case string:
		return tt, nil
	case float64:
		if math.Trunc(tt) == tt {
			return strconv.FormatFloat(tt, 'f', 0, 64), nil
		} else {
			return strconv.FormatFloat(tt, 'f', 2, 64), nil
		}
	case nil:
		return "", nil
	case bool:
		return fmt.Sprintf("%v", tt), nil
	default:
		return "", fmt.Errorf("cannot convert type to string: %v", tt)
	}
}

func timeAgo(ago time.Duration) string {
	if ago < time.Minute {
		return "just now"
	}
	if ago < time.Hour {
		return text.Pluralize(int(ago.Minutes()), "minute") + " ago"
	}
	if ago < 24*time.Hour {
		return text.Pluralize(int(ago.Hours()), "hour") + " ago"
	}
	if ago < 30*24*time.Hour {
		return text.Pluralize(int(ago.Hours())/24, "day") + " ago"
	}
	if ago < 365*24*time.Hour {
		return text.Pluralize(int(ago.Hours())/24/30, "month") + " ago"
	}
	return text.Pluralize(int(ago.Hours()/24/365), "year") + " ago"
}

// TruncateMultiline returns a copy of the string s that has been shortened to fit the maximum
// display width. If string s has multiple lines the first line will be shortened and all others
// removed.
func truncateMultiline(maxWidth int, s string) string {
	if i := strings.IndexAny(s, "\r\n"); i >= 0 {
		s = s[:i] + ellipsis
	}
	return text.Truncate(maxWidth, s)
}

func hyperlinkFunc(link, text string) string {
	if text == "" {
		text = link
	}

	// See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
	return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", link, text)
}
