// Copyright 2019 Prometheus Team
// 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 test

import (
	"errors"
	"fmt"
	"maps"
	"os"
	"os/exec"
	"regexp"
	"strings"
	"testing"
	"time"

	"github.com/go-openapi/strfmt"

	"github.com/prometheus/alertmanager/api/v2/models"
	"github.com/prometheus/alertmanager/cli/format"
	"github.com/prometheus/alertmanager/test/testutils"
)

const (
	// nolint:godot
	// amtool is the relative path to local amtool binary.
	amtool = "../../../amtool"
)

// Re-export common types from testutils.
type (
	Collector      = testutils.Collector
	AcceptanceOpts = testutils.AcceptanceOpts
)

var CompareCollectors = testutils.CompareCollectors

// AcceptanceTest wraps testutils.AcceptanceTest for CLI-based testing.
type AcceptanceTest struct {
	*testutils.AcceptanceTest
}

// NewAcceptanceTest returns a new acceptance test.
func NewAcceptanceTest(t *testing.T, opts *AcceptanceOpts) *AcceptanceTest {
	return &AcceptanceTest{
		AcceptanceTest: testutils.NewAcceptanceTest(t, opts),
	}
}

// AmtoolOk verifies that the "amtool" file exists in the correct location for testing,
// and is a regular file.
func AmtoolOk() (bool, error) {
	stat, err := os.Stat(amtool)
	if err != nil {
		return false, fmt.Errorf("error accessing amtool command, try 'make build' to generate the file. %w", err)
	} else if stat.IsDir() {
		return false, fmt.Errorf("file %s is a directory, expecting a binary executable file", amtool)
	}
	return true, nil
}

// Alertmanager wraps testutils.Alertmanager and adds CLI-specific methods.
type Alertmanager struct {
	*testutils.Alertmanager
}

// AlertmanagerCluster wraps testutils.AlertmanagerCluster and adds CLI-specific methods.
type AlertmanagerCluster struct {
	*testutils.AlertmanagerCluster
}

// AlertmanagerCluster returns a new AlertmanagerCluster.
func (t *AcceptanceTest) AlertmanagerCluster(conf string, size int) *AlertmanagerCluster {
	return &AlertmanagerCluster{
		AlertmanagerCluster: t.AcceptanceTest.AlertmanagerCluster(conf, size),
	}
}

// Members returns the underlying Alertmanager instances wrapped for CLI testing.
func (amc *AlertmanagerCluster) Members() []*Alertmanager {
	baseMembers := amc.AlertmanagerCluster.Members()
	wrapped := make([]*Alertmanager, len(baseMembers))
	for i, am := range baseMembers {
		wrapped[i] = &Alertmanager{Alertmanager: am}
	}
	return wrapped
}

// AddAlertsAt declares alerts that are to be added to the Alertmanager server
// at a relative point in time.
func (am *Alertmanager) AddAlertsAt(omitEquals bool, at float64, alerts ...*TestAlert) {
	am.T.Do(at, func() {
		am.AddAlerts(omitEquals, alerts...)
	})
}

// AddAlerts declares alerts that are to be added to the Alertmanager server.
// The omitEquals option omits alertname= from the command line args passed to
// amtool and instead uses the alertname value as the first argument to the command.
// For example `amtool alert add foo` instead of `amtool alert add alertname=foo`.
// This has been added to allow certain tests to test adding alerts both with and
// without alertname=. All other tests that use AddAlerts as a fixture can set this
// to false.
func (am *Alertmanager) AddAlerts(omitEquals bool, alerts ...*TestAlert) {
	for _, alert := range alerts {
		out, err := am.addAlertCommand(omitEquals, alert)
		if err != nil {
			am.T.Errorf("Error adding alert: %v\nOutput: %s", err, string(out))
		}
	}
}

func (am *Alertmanager) addAlertCommand(omitEquals bool, alert *TestAlert) ([]byte, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := []string{amURLFlag, "alert", "add"}
	// Make a copy of the labels
	labels := make(models.LabelSet, len(alert.Labels))
	maps.Copy(labels, alert.Labels)
	if omitEquals {
		// If alertname is present and omitEquals is true then the command should
		// be `amtool alert add foo ...` and not `amtool alert add alertname=foo ...`.
		if alertname, ok := labels["alertname"]; ok {
			args = append(args, alertname)
			delete(labels, "alertname")
		}
	}
	for k, v := range labels {
		args = append(args, k+"="+v)
	}
	startsAt := strfmt.DateTime(am.Opts.ExpandTime(alert.StartsAt))
	args = append(args, "--start="+startsAt.String())
	if alert.EndsAt > alert.StartsAt {
		endsAt := strfmt.DateTime(am.Opts.ExpandTime(alert.EndsAt))
		args = append(args, "--end="+endsAt.String())
	}
	cmd := exec.Command(amtool, args...)
	return cmd.CombinedOutput()
}

// QueryAlerts uses the amtool cli to query alerts.
func (am *Alertmanager) QueryAlerts(match ...string) ([]TestAlert, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := append([]string{amURLFlag, "alert", "query"}, match...)
	cmd := exec.Command(amtool, args...)
	output, err := cmd.CombinedOutput()
	if err != nil {
		return nil, err
	}
	return parseAlertQueryResponse(output)
}

func parseAlertQueryResponse(data []byte) ([]TestAlert, error) {
	alerts := []TestAlert{}
	lines := strings.Split(string(data), "\n")
	header, lines := lines[0], lines[1:len(lines)-1]
	startTimePos := strings.Index(header, "Starts At")
	if startTimePos == -1 {
		return alerts, errors.New("Invalid header: " + header)
	}
	summPos := strings.Index(header, "Summary")
	if summPos == -1 {
		return alerts, errors.New("Invalid header: " + header)
	}
	for _, line := range lines {
		alertName := strings.TrimSpace(line[0:startTimePos])
		startTime := strings.TrimSpace(line[startTimePos:summPos])
		startsAt, err := time.Parse(format.DefaultDateFormat, startTime)
		if err != nil {
			return alerts, err
		}
		summary := strings.TrimSpace(line[summPos:])
		alert := TestAlert{
			Labels:   models.LabelSet{"alertname": alertName},
			StartsAt: float64(startsAt.Unix()),
			Summary:  summary,
		}
		alerts = append(alerts, alert)
	}
	return alerts, nil
}

// SetSilence updates or creates the given Silence.
func (amc *AlertmanagerCluster) SetSilence(at float64, sil *TestSilence) {
	for _, am := range amc.Members() {
		am.SetSilence(at, sil)
	}
}

// SetSilence updates or creates the given Silence.
func (am *Alertmanager) SetSilence(at float64, sil *TestSilence) {
	out, err := am.addSilenceCommand(sil)
	if err != nil {
		am.T.Errorf("Unable to set silence %v %v", err, string(out))
	}
}

// addSilenceCommand adds a silence using the 'amtool silence add' command.
func (am *Alertmanager) addSilenceCommand(sil *TestSilence) ([]byte, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := []string{amURLFlag, "silence", "add"}
	if sil.comment != "" {
		args = append(args, "--comment="+sil.comment)
	}
	args = append(args, sil.match...)
	cmd := exec.Command(amtool, args...)
	return cmd.CombinedOutput()
}

// QuerySilence queries the current silences using the 'amtool silence query' command.
func (am *Alertmanager) QuerySilence(match ...string) ([]TestSilence, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := append([]string{amURLFlag, "silence", "query"}, match...)
	cmd := exec.Command(amtool, args...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		am.T.Error("Silence query command failed: ", err)
	}
	return parseSilenceQueryResponse(out)
}

// QueryExpiredSilence queries expired silences using the 'amtool silence query --expired --within' command.
func (am *Alertmanager) QueryExpiredSilence(match ...string) ([]TestSilence, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := append([]string{amURLFlag, "silence", "query", "--expired", "--within=1h"}, match...)
	cmd := exec.Command(amtool, args...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		am.T.Error("Silence query command failed: ", err)
	}
	return parseSilenceQueryResponse(out)
}

var silenceHeaderFields = []string{"ID", "Matchers", "Ends At", "Created By", "Comment"}

func parseSilenceQueryResponse(data []byte) ([]TestSilence, error) {
	sils := []TestSilence{}
	lines := strings.Split(string(data), "\n")
	header, lines := lines[0], lines[1:len(lines)-1]
	matchersPos := strings.Index(header, silenceHeaderFields[1])
	if matchersPos == -1 {
		return sils, errors.New("Invalid header: " + header)
	}
	endsAtPos := strings.Index(header, silenceHeaderFields[2])
	if endsAtPos == -1 {
		return sils, errors.New("Invalid header: " + header)
	}
	createdByPos := strings.Index(header, silenceHeaderFields[3])
	if createdByPos == -1 {
		return sils, errors.New("Invalid header: " + header)
	}
	commentPos := strings.Index(header, silenceHeaderFields[4])
	if commentPos == -1 {
		return sils, errors.New("Invalid header: " + header)
	}
	for _, line := range lines {
		id := strings.TrimSpace(line[0:matchersPos])
		matchers := strings.TrimSpace(line[matchersPos:endsAtPos])
		endsAtString := strings.TrimSpace(line[endsAtPos:createdByPos])
		endsAt, err := time.Parse(format.DefaultDateFormat, endsAtString)
		if err != nil {
			return sils, err
		}
		createdBy := strings.TrimSpace(line[createdByPos:commentPos])
		comment := strings.TrimSpace(line[commentPos:])
		silence := TestSilence{
			id:        id,
			endsAt:    float64(endsAt.Unix()),
			match:     strings.Split(matchers, " "),
			createdBy: createdBy,
			comment:   comment,
		}
		sils = append(sils, silence)
	}
	return sils, nil
}

// DelSilence deletes the silence with the sid at the given time.
func (amc *AlertmanagerCluster) DelSilence(at float64, sil *TestSilence) {
	for _, am := range amc.Members() {
		am.DelSilence(at, sil)
	}
}

// DelSilence deletes the silence with the sid at the given time.
func (am *Alertmanager) DelSilence(at float64, sil *TestSilence) {
	output, err := am.expireSilenceCommand(sil)
	if err != nil {
		am.T.Errorf("Error expiring silence %v: %s", string(output), err)
		return
	}
}

// expireSilenceCommand expires a silence using the 'amtool silence expire' command.
func (am *Alertmanager) expireSilenceCommand(sil *TestSilence) ([]byte, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := []string{amURLFlag, "silence", "expire", sil.ID()}
	cmd := exec.Command(amtool, args...)
	return cmd.CombinedOutput()
}

// ExportSilences exports all silences to JSON format using 'amtool silence query -o json'.
func (am *Alertmanager) ExportSilences() ([]byte, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := []string{amURLFlag, "silence", "query", "-o", "json"}
	cmd := exec.Command(amtool, args...)
	return cmd.Output()
}

// ImportSilences imports silences from a JSON file using 'amtool silence import'.
func (am *Alertmanager) ImportSilences(filename string) ([]byte, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := []string{amURLFlag, "silence", "import", filename}
	cmd := exec.Command(amtool, args...)
	return cmd.CombinedOutput()
}

// ExpireSilenceByID expires a silence by its ID using 'amtool silence expire'.
func (am *Alertmanager) ExpireSilenceByID(id string) ([]byte, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := []string{amURLFlag, "silence", "expire", id}
	cmd := exec.Command(amtool, args...)
	return cmd.CombinedOutput()
}

// ShowRoute shows the routing tree using 'amtool config routes show'.
func (am *Alertmanager) ShowRoute() ([]byte, error) {
	return am.showRouteCommand()
}

func (am *Alertmanager) showRouteCommand() ([]byte, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := []string{amURLFlag, "config", "routes", "show"}
	cmd := exec.Command(amtool, args...)
	return cmd.CombinedOutput()
}

// TestRoute tests label matching against the routing tree using 'amtool config routes test'.
func (am *Alertmanager) TestRoute(labels ...string) ([]byte, error) {
	return am.testRouteCommand(labels...)
}

func (am *Alertmanager) testRouteCommand(labels ...string) ([]byte, error) {
	amURLFlag := "--alertmanager.url=" + am.getURL("/")
	args := append([]string{amURLFlag, "config", "routes", "test"}, labels...)
	cmd := exec.Command(amtool, args...)
	return cmd.CombinedOutput()
}

func (am *Alertmanager) getURL(path string) string {
	return fmt.Sprintf("http://%s%s%s", am.APIAddr(), am.Opts.RoutePrefix, path)
}

// Version runs the 'amtool' command with the --version option and checks
// for appropriate output.
func Version() (string, error) {
	cmd := exec.Command(amtool, "--version")
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", err
	}

	versionRE := regexp.MustCompile(`^amtool, version (\d+\.\d+\.\d+) *`)
	matched := versionRE.FindStringSubmatch(string(out))
	if len(matched) != 2 {
		return "", errors.New("Unable to match version info regex: " + string(out))
	}
	return matched[1], nil
}
