// Licensed to Apache Software Foundation (ASF) under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Apache Software Foundation (ASF) licenses this file to you 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 dashboard

import (
	"context"
	"fmt"
	"math"
	"strings"
	"time"

	api "skywalking.apache.org/repo/goapi/query"

	"github.com/apache/skywalking-cli/internal/commands/interceptor"
	"github.com/apache/skywalking-cli/pkg/contextkey"
	"github.com/apache/skywalking-cli/pkg/graphql/utils"
	lib "github.com/apache/skywalking-cli/pkg/heatmap"

	"github.com/mattn/go-runewidth"
	"github.com/mum4k/termdash"
	"github.com/mum4k/termdash/container/grid"
	"github.com/mum4k/termdash/linestyle"
	"github.com/mum4k/termdash/terminal/termbox"
	"github.com/mum4k/termdash/terminal/terminalapi"

	"github.com/apache/skywalking-cli/pkg/display/graph/gauge"
	"github.com/apache/skywalking-cli/pkg/display/graph/heatmap"
	"github.com/apache/skywalking-cli/pkg/display/graph/linear"
	"github.com/apache/skywalking-cli/pkg/graphql/dashboard"

	"github.com/mum4k/termdash/cell"
	"github.com/mum4k/termdash/container"
	"github.com/mum4k/termdash/widgets/button"
	"github.com/mum4k/termdash/widgets/linechart"
)

// rootID is the ID assigned to the root container.
const rootID = "root"

type layoutType int

const (
	// layoutMetrics displays all the widgets.
	layoutMetrics layoutType = iota

	// layoutLineChart focuses onto the line chart.
	layoutLineChart

	// layoutHeatMap focuses onto the heat map.
	layoutHeatMap
)

// strToLayoutType ensures the order of buttons is fixed.
var strToLayoutType = map[string]layoutType{
	"Metrics":         layoutMetrics,
	"ResponseLatency": layoutLineChart,
	"HeatMap":         layoutHeatMap,
}

// widgets holds the widgets used by the dashboard.
type widgets struct {
	gauges  []*gauge.MetricColumn
	linears map[string]*linechart.LineChart
	heatmap *lib.HeatMap

	// buttons are used to change the layout.
	buttons []*button.Button
}

// template determines how the global dashboard is displayed.
var template *dashboard.GlobalTemplate

var allWidgets *widgets

var (
	initStartStr string
	initStep     = api.StepMinute
	initEndStr   string
)

var (
	curStartTime time.Time
	curEndTime   time.Time
)

// setLayout sets the specified layout.
func setLayout(c *container.Container, lt layoutType) error {
	gridOpts, err := gridLayout(lt)
	if err != nil {
		return err
	}
	return c.Update(rootID, gridOpts...)
}

// newLayoutButtons returns buttons that dynamically switch the layouts.
func newLayoutButtons(c *container.Container) ([]*button.Button, error) {
	buttons := make([]*button.Button, len(strToLayoutType))

	ls := longestString(template.Buttons.Texts)
	if ls == "" {
		return nil, fmt.Errorf("failed to parse texts of buttons")
	}

	opts := []button.Option{
		button.WidthFor(ls),
		button.FillColor(cell.ColorNumber(template.Buttons.ColorNum)),
		button.Height(template.Buttons.Height),
	}

	for _, text := range template.Buttons.Texts {
		// declare a local variable lt to avoid closure.
		lt, ok := strToLayoutType[text]
		if !ok {
			return nil, fmt.Errorf("the '%s' is not supposed to be the button's text", text)
		}

		b, err := button.New(text, func() error {
			return setLayout(c, lt)
		}, opts...)
		if err != nil {
			return nil, err
		}

		buttons[lt] = b
	}

	return buttons, nil
}

// gridLayout prepares container options that represent the desired screen layout.
func gridLayout(lt layoutType) ([]container.Option, error) {
	const buttonRowHeight = 15

	buttonColWidthPerc := 99 / len(allWidgets.buttons)
	var buttonCols []grid.Element

	for _, b := range allWidgets.buttons {
		if b != nil {
			buttonCols = append(buttonCols, grid.ColWidthPerc(buttonColWidthPerc, grid.Widget(b)))
		}
	}

	rows := []grid.Element{
		grid.RowHeightPerc(buttonRowHeight, buttonCols...),
	}

	switch lt {
	case layoutMetrics:
		rows = append(rows,
			grid.RowHeightPerc(70, gauge.MetricColumnsElement(allWidgets.gauges)...),
		)

	case layoutLineChart:
		lcElements := linear.LineChartElements(allWidgets.linears)
		percentage := int(math.Min(99, float64((100-buttonRowHeight)/len(lcElements))))

		for _, e := range lcElements {
			rows = append(rows,
				grid.RowHeightPerc(percentage, e...),
			)
		}

	case layoutHeatMap:
		const heatmapColWidth = 85

		rows = append(rows,
			grid.RowHeightPerc(
				99-buttonRowHeight,
				grid.ColWidthPerc((99-heatmapColWidth)/2), // Use two empty cols to center the heatmap.
				grid.ColWidthPerc(heatmapColWidth, grid.Widget(allWidgets.heatmap)),
				grid.ColWidthPerc((99-heatmapColWidth)/2),
			),
		)
	}

	builder := grid.New()
	builder.Add(
		grid.RowHeightPerc(99, rows...),
	)
	gridOpts, err := builder.Build()
	if err != nil {
		return nil, err
	}
	return gridOpts, nil
}

// newWidgets creates all widgets used by the dashboard.
func newWidgets(data *dashboard.GlobalData) error {
	var columns []*gauge.MetricColumn
	linears := make(map[string]*linechart.LineChart)

	// Create gauges to display global metrics.
	for i := range template.Metrics {
		col, err := gauge.NewMetricColumn(data.Metrics[i], &template.Metrics[i])
		if err != nil {
			return err
		}
		columns = append(columns, col)
	}

	// Create line charts to display global response latency.
	for label, input := range data.ResponseLatency {
		l, err := linear.NewLineChart(input)
		if err != nil {
			return err
		}
		linears[label] = l
	}

	// Create a heat map.
	hp, err := heatmap.NewHeatMapWidget(data.HeatMap)
	if err != nil {
		return err
	}

	allWidgets.gauges = columns
	allWidgets.linears = linears
	allWidgets.heatmap = hp
	return nil
}

func Display(ctx context.Context, data *dashboard.GlobalData) error {
	t, err := termbox.New(termbox.ColorMode(terminalapi.ColorMode256))
	if err != nil {
		return err
	}
	defer t.Close()

	c, err := container.New(
		t,
		container.Border(linestyle.Light),
		container.BorderTitle("[Global Dashboard]-PRESS Q TO QUIT"),
		container.ID(rootID))
	if err != nil {
		return err
	}

	te, err := dashboard.LoadTemplate(ctx.Value(contextkey.DashboardTemplate{}).(string))
	if err != nil {
		return err
	}
	template = te

	// Initialization
	allWidgets = &widgets{
		gauges:  nil,
		linears: nil,
		heatmap: nil,
		buttons: nil,
	}
	err = newWidgets(data)
	if err != nil {
		return err
	}
	lb, err := newLayoutButtons(c)
	if err != nil {
		return err
	}
	allWidgets.buttons = lb

	gridOpts, err := gridLayout(layoutMetrics)
	if err != nil {
		return err
	}

	if e := c.Update(rootID, gridOpts...); e != nil {
		return e
	}

	var cancel context.CancelFunc
	ctx, cancel = context.WithCancel(ctx)
	quitter := func(keyboard *terminalapi.Keyboard) {
		if strings.EqualFold(keyboard.Key.String(), "q") {
			cancel()
		}
	}

	refreshInterval := time.Duration(ctx.Value(contextkey.DashboardRefreshInterval{}).(int)) * time.Second
	dt := ctx.Value(contextkey.DurationType{}).(utils.DurationType)

	// Only when users use the relative time, the duration will be adjusted to refresh.
	if dt != utils.BothPresent {
		go refresh(ctx, refreshInterval)
	}

	err = termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(refreshInterval))

	return err
}

// longestString returns the longest string in the string array.
func longestString(strs []string) (ret string) {
	maxLen := 0
	for _, s := range strs {
		if l := runewidth.StringWidth(s); l > maxLen {
			ret = s
			maxLen = l
		}
	}
	return
}

// refresh updates the duration and query the new data to update all of widgets, once every delay.
func refresh(ctx context.Context, interval time.Duration) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	initStartStr = ctx.Value(contextkey.DurationStart{}).(string)
	initEndStr = ctx.Value(contextkey.DurationEnd{}).(string)
	initStep = ctx.Value(contextkey.DurationStep{}).(api.Step)

	_, start, err := interceptor.TryParseTime(initStartStr, initStep)
	if err != nil {
		return
	}
	_, end, err := interceptor.TryParseTime(initEndStr, initStep)
	if err != nil {
		return
	}

	curStartTime = start
	curEndTime = end

	for {
		select {
		case <-ticker.C:
			d, err := updateDuration(interval)
			if err != nil {
				continue
			}

			data, err := dashboard.Global(ctx, d)
			if err != nil {
				continue
			}

			if err := updateAllWidgets(data); err != nil {
				continue
			}
		case <-ctx.Done():
			return
		}
	}
}

// updateDuration will check if the duration changes after adding the interval.
// If the duration doesn't change, an error will be returned, and the dashboard will not refresh.
// Otherwise, a new duration will be returned, which is used to get the latest global data.
func updateDuration(interval time.Duration) (api.Duration, error) {
	step, _, err := interceptor.TryParseTime(initStartStr, initStep)
	if err != nil {
		return api.Duration{}, err
	}

	curStartTime = curStartTime.Add(interval)
	curEndTime = curEndTime.Add(interval)

	curStartStr := curStartTime.Format(utils.StepFormats[step])
	curEndStr := curEndTime.Format(utils.StepFormats[step])

	if curStartStr == initStartStr && curEndStr == initEndStr {
		return api.Duration{}, fmt.Errorf("the duration does not update")
	}

	initStartStr = curStartStr
	initEndStr = curEndStr
	return api.Duration{
		Start: curStartStr,
		End:   curEndStr,
		Step:  step,
	}, nil
}

// updateAllWidgets will update all of widgets' data to be displayed.
func updateAllWidgets(data *dashboard.GlobalData) error {
	// Update gauges
	for i, mcData := range data.Metrics {
		if err := allWidgets.gauges[i].Update(mcData); err != nil {
			return err
		}
	}

	// Update line charts.
	for i, inputs := range data.ResponseLatency {
		if err := linear.SetLineChartSeries(allWidgets.linears[i], inputs); err != nil {
			return err
		}
	}

	// Update the heat map.
	heatmap.SetData(allWidgets.heatmap, data.HeatMap)

	return nil
}
