package proc

import (
	"context"
	"encoding/json"
	"fmt"
	"maps"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strings"

	"github.com/pingcap/errors"
	logprinter "github.com/pingcap/tiup/pkg/logger/printer"
	"github.com/pingcap/tiup/pkg/utils"
)

const (
	// ServicePrometheus is the service ID for the Prometheus instance.
	ServicePrometheus ServiceID = "prometheus"
	// ServiceGrafana is the service ID for the Grafana instance.
	ServiceGrafana ServiceID = "grafana"
	// ServiceNGMonitoring is the service ID for NG Monitoring.
	ServiceNGMonitoring ServiceID = "ng-monitoring"

	// ComponentPrometheus is the repository component ID for Prometheus.
	ComponentPrometheus RepoComponentID = "prometheus"
	// ComponentGrafana is the repository component ID for Grafana.
	ComponentGrafana RepoComponentID = "grafana"
	// ComponentNGMonitoring is the repository component ID for NG Monitoring.
	ComponentNGMonitoring RepoComponentID = "ng-monitoring"
)

func init() {
	RegisterComponentDisplayName(ComponentPrometheus, "Prometheus")
	RegisterServiceDisplayName(ServicePrometheus, "Prometheus")
	registerPlannedProcessFactory(ServicePrometheus, func(_ ServicePlan, info ProcessInfo, _ SharedOptions, _ string) (Process, error) {
		return &PrometheusInstance{ProcessInfo: info}, nil
	})

	RegisterComponentDisplayName(ComponentGrafana, "Grafana")
	RegisterServiceDisplayName(ServiceGrafana, "Grafana")
	registerPlannedProcessFactory(ServiceGrafana, func(plan ServicePlan, info ProcessInfo, _ SharedOptions, _ string) (Process, error) {
		promURL := ""
		if plan.Grafana != nil {
			promURL = plan.Grafana.PrometheusURL
		}
		return &GrafanaInstance{PrometheusURL: promURL, ProcessInfo: info}, nil
	})

	RegisterComponentDisplayName(ComponentNGMonitoring, "NG Monitoring")
	RegisterServiceDisplayName(ServiceNGMonitoring, "NG Monitoring")
	registerPlannedProcessFactory(ServiceNGMonitoring, func(plan ServicePlan, info ProcessInfo, _ SharedOptions, _ string) (Process, error) {
		if plan.NGMonitoring == nil {
			name := info.Name()
			if name == "" {
				name = ServiceNGMonitoring.String()
			}
			return nil, errors.Errorf("missing ng-monitoring plan for %s", name)
		}
		return &NGMonitoringInstance{Plan: *plan.NGMonitoring, ProcessInfo: info}, nil
	})
}

// PrometheusInstance represents a running Prometheus server.
type PrometheusInstance struct {
	ProcessInfo

	sdFile string
}

var _ Process = &PrometheusInstance{}

// LogFile returns the log file path for the instance.
func (inst *PrometheusInstance) LogFile() string {
	return filepath.Join(inst.Dir, "prom.log")
}

// RenderSDFile writes Prometheus file_sd targets for all instances.
// ref: https://prometheus.io/docs/prometheus/latest/configuration/configuration/#file_sd_config
func (inst *PrometheusInstance) RenderSDFile(sid2targets map[ServiceID]MetricAddr) error {
	if inst == nil {
		return nil
	}

	if inst.sdFile == "" {
		inst.sdFile = filepath.Join(inst.Dir, "targets.json")
	}
	if sid2targets == nil {
		sid2targets = make(map[ServiceID]MetricAddr)
	}

	sid2targets[ServicePrometheus] = MetricAddr{Targets: []string{utils.JoinHostPort(inst.Host, inst.Port)}}

	var orderedIDs []ServiceID
	for id, t := range sid2targets {
		if len(t.Targets) == 0 {
			continue
		}
		orderedIDs = append(orderedIDs, id)
	}
	slices.SortStableFunc(orderedIDs, func(a, b ServiceID) int {
		return strings.Compare(a.String(), b.String())
	})

	items := make([]MetricAddr, 0, len(orderedIDs))
	for _, id := range orderedIDs {
		t := sid2targets[id]

		targets := append([]string(nil), t.Targets...)
		slices.Sort(targets)
		targets = slices.Compact(targets)

		it := MetricAddr{
			Targets: targets,
			Labels:  map[string]string{"job": id.String()},
		}
		maps.Copy(it.Labels, t.Labels)
		items = append(items, it)
	}

	data, err := json.MarshalIndent(&items, "", "\t")
	if err != nil {
		return errors.AddStack(err)
	}
	if err := utils.WriteFile(inst.sdFile, data, 0644); err != nil {
		return errors.AddStack(err)
	}
	return nil
}

// Prepare builds the Prometheus process command.
func (inst *PrometheusInstance) Prepare(ctx context.Context) error {
	if inst == nil {
		return errors.New("prometheus instance is nil")
	}
	if inst.Dir == "" {
		return errors.New("prometheus dir is empty")
	}
	if inst.BinPath == "" {
		return errors.New("prometheus binary not resolved")
	}
	if err := utils.MkdirAll(inst.Dir, 0755); err != nil {
		return errors.AddStack(err)
	}

	addr := utils.JoinHostPort(inst.Host, inst.Port)

	tmpl := `
global:
  scrape_interval:     15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
  alertmanagers:
  - static_configs:
    - targets:
      # - alertmanager:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
  # - "first_rules.yml"
  # - "second_rules.yml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  - job_name: 'cluster'
    file_sd_configs:
    - files:
      - targets.json

`

	configPath := filepath.Join(inst.Dir, "prometheus.yml")
	if err := utils.WriteFile(configPath, []byte(tmpl), 0644); err != nil {
		return errors.AddStack(err)
	}
	inst.sdFile = filepath.Join(inst.Dir, "targets.json")
	_ = utils.WriteFile(inst.sdFile, []byte("[]"), 0644)

	args := []string{
		fmt.Sprintf("--config.file=%s", configPath),
		fmt.Sprintf("--web.external-url=http://%s", addr),
		fmt.Sprintf("--web.listen-address=%s", utils.JoinHostPort(inst.Host, inst.Port)),
		fmt.Sprintf("--storage.tsdb.path=%s", filepath.Join(inst.Dir, "data")),
	}
	inst.Proc = &cmdProcess{cmd: PrepareCommand(ctx, inst.BinPath, args, nil, inst.Dir)}
	return nil
}

// GrafanaPlan is the service-specific plan for Grafana.
type GrafanaPlan struct {
	PrometheusURL string // http://host:port
}

// GrafanaInstance represents a running Grafana server.
type GrafanaInstance struct {
	ProcessInfo

	PrometheusURL string
}

var _ Process = &GrafanaInstance{}

// LogFile returns the log file path for the instance.
func (inst *GrafanaInstance) LogFile() string {
	return filepath.Join(inst.Dir, "grafana.log")
}

const grafanaClusterName = "Test-Cluster"

func writeDatasourceConfig(fname string, clusterName string, p8sURL string) error {
	if err := utils.MkdirAll(filepath.Dir(fname), 0755); err != nil {
		return errors.AddStack(err)
	}

	tpl := `apiVersion: 1
datasources:
  - name: %s
    type: prometheus
    access: proxy
    url: %s
    withCredentials: false
    isDefault: false
    tlsAuth: false
    tlsAuthWithCACert: false
    version: 1
    editable: true
`

	s := fmt.Sprintf(tpl, clusterName, p8sURL)
	if err := utils.WriteFile(fname, []byte(s), 0644); err != nil {
		return errors.AddStack(err)
	}
	return nil
}

// replaceDatasource replaces dashboard datasource placeholders with the one we
// are using.
// ref: templates/scripts/run_grafana.sh.tpl
func replaceDatasource(dashboardDir string, datasourceName string) error {
	// for "s/\${DS_.*-CLUSTER}/datasourceName/g"
	re := regexp.MustCompile(`\${DS_.*-CLUSTER}`)

	return filepath.Walk(dashboardDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			logprinter.Warnf("skip scan %s failed: %v", path, err)
			return nil
		}
		if info.IsDir() {
			return nil
		}

		data, err := os.ReadFile(path)
		if err != nil {
			return errors.AddStack(err)
		}

		s := string(data)
		s = strings.ReplaceAll(s, "test-cluster", datasourceName)
		s = strings.ReplaceAll(s, "Test-Cluster", datasourceName)
		s = strings.ReplaceAll(s, "${DS_LIGHTNING}", datasourceName)
		s = re.ReplaceAllLiteralString(s, datasourceName)

		return utils.WriteFile(path, []byte(s), 0644)
	})
}

func writeDashboardConfig(fname string, clusterName string, dir string) error {
	if err := utils.MkdirAll(filepath.Dir(fname), 0755); err != nil {
		return errors.AddStack(err)
	}

	tpl := `apiVersion: 1
providers:
  - name: %s
    folder: %s
    type: file
    disableDeletion: false
    allowUiUpdates: true
    editable: true
    updateIntervalSeconds: 30
    options:
      path: %s
`
	s := fmt.Sprintf(tpl, clusterName, clusterName, dir)
	if err := utils.WriteFile(fname, []byte(s), 0644); err != nil {
		return errors.AddStack(err)
	}
	return nil
}

func resolveGrafanaHome(binPath string) string {
	if binPath == "" {
		return ""
	}

	// Best-effort resolve symlinks so we can walk the real install directory.
	// If it fails, keep the original path.
	if resolved, err := filepath.EvalSymlinks(binPath); err == nil && resolved != "" {
		binPath = resolved
	}

	dir := binPath
	if st, err := os.Stat(binPath); err == nil && !st.IsDir() {
		dir = filepath.Dir(binPath)
	}

	for range 8 {
		defaults := filepath.Join(dir, "conf", "defaults.ini")
		if st, err := os.Stat(defaults); err == nil && !st.IsDir() {
			return dir
		}
		parent := filepath.Dir(dir)
		if parent == dir {
			break
		}
		dir = parent
	}
	return ""
}

func copyGrafanaDashboards(home, dashboardDir string) error {
	entries, err := os.ReadDir(home)
	if err != nil {
		return errors.AddStack(err)
	}
	for _, ent := range entries {
		if ent.IsDir() {
			continue
		}
		name := ent.Name()
		if !strings.HasSuffix(name, ".json") {
			continue
		}
		data, err := os.ReadFile(filepath.Join(home, name))
		if err != nil {
			return errors.AddStack(err)
		}
		if err := utils.WriteFile(filepath.Join(dashboardDir, name), data, 0644); err != nil {
			return errors.AddStack(err)
		}
	}
	return nil
}

// Prepare builds the Grafana process command.
func (inst *GrafanaInstance) Prepare(ctx context.Context) error {
	if inst == nil {
		return errors.New("grafana instance is nil")
	}
	info := inst.Info()
	if inst.Dir == "" {
		return errors.New("grafana dir is empty")
	}
	if inst.BinPath == "" {
		return errors.New("grafana binary not resolved")
	}
	if err := utils.MkdirAll(inst.Dir, 0755); err != nil {
		return errors.AddStack(err)
	}

	home := resolveGrafanaHome(inst.BinPath)
	if home == "" {
		return errors.New("cannot resolve grafana home")
	}

	dashboardDir := filepath.Join(inst.Dir, "dashboards")
	if err := utils.MkdirAll(dashboardDir, 0755); err != nil {
		return errors.AddStack(err)
	}
	if err := copyGrafanaDashboards(home, dashboardDir); err != nil {
		return err
	}
	if err := replaceDatasource(dashboardDir, grafanaClusterName); err != nil {
		return err
	}

	provisioningDir := filepath.Join(inst.Dir, "conf", "provisioning")
	for _, sub := range []string{"dashboards", "datasources", "plugins", "notifiers"} {
		if err := utils.MkdirAll(filepath.Join(provisioningDir, sub), 0755); err != nil {
			return errors.AddStack(err)
		}
	}

	if err := writeDashboardConfig(
		filepath.Join(provisioningDir, "dashboards", "dashboard.yml"),
		grafanaClusterName,
		dashboardDir,
	); err != nil {
		return err
	}

	if err := writeDatasourceConfig(
		filepath.Join(provisioningDir, "datasources", "datasource.yml"),
		grafanaClusterName,
		inst.PrometheusURL,
	); err != nil {
		return err
	}

	custom := fmt.Sprintf(`
[server]
# The ip address to bind to, empty will bind to all interfaces
http_addr = %s

# The http port to use
http_port = %d
`, inst.Host, inst.Port)

	if err := utils.MkdirAll(filepath.Join(inst.Dir, "conf"), 0755); err != nil {
		return errors.AddStack(err)
	}
	customFile := filepath.Join(inst.Dir, "conf", "custom.ini")
	if err := utils.WriteFile(customFile, []byte(custom), 0644); err != nil {
		return errors.AddStack(err)
	}

	args := []string{
		"--homepath", home,
		"--config", customFile,
		fmt.Sprintf("cfg:default.paths.data=%s", filepath.Join(inst.Dir, "data")),
		fmt.Sprintf("cfg:default.paths.logs=%s", filepath.Join(inst.Dir, "log")),
		fmt.Sprintf("cfg:default.paths.plugins=%s", filepath.Join(inst.Dir, "plugins")),
		fmt.Sprintf("cfg:default.paths.provisioning=%s", filepath.Join(inst.Dir, "conf", "provisioning")),
	}
	info.Proc = &cmdProcess{cmd: PrepareCommand(ctx, inst.BinPath, args, nil, home)}
	return nil
}

// NGMonitoringPlan is the service-specific plan for NG Monitoring.
type NGMonitoringPlan struct {
	PDAddrs []string
}

// NGMonitoringInstance represents a running ng-monitoring-server.
type NGMonitoringInstance struct {
	ProcessInfo

	Plan NGMonitoringPlan
}

var _ Process = &NGMonitoringInstance{}

// LogFile returns the log file path for the instance.
func (inst *NGMonitoringInstance) LogFile() string {
	return filepath.Join(inst.Dir, "ng-monitoring.log")
}

// Prepare builds the NG Monitoring process command.
func (inst *NGMonitoringInstance) Prepare(ctx context.Context) error {
	if inst == nil {
		return errors.New("ng-monitoring instance is nil")
	}
	info := inst.Info()
	if inst.Dir == "" {
		return errors.New("ng-monitoring dir is empty")
	}
	if inst.BinPath == "" {
		return errors.New("ng-monitoring binary not resolved")
	}

	if err := utils.MkdirAll(inst.Dir, 0755); err != nil {
		return errors.AddStack(err)
	}

	binPath := inst.BinPath
	if filepath.Base(binPath) != "ng-monitoring-server" {
		if alt, ok := ResolveSiblingBinary(binPath, "ng-monitoring-server"); ok {
			binPath = alt
		} else {
			return errors.Errorf("ng-monitoring-server not found near %s", inst.BinPath)
		}
	}

	endpoints := append([]string(nil), inst.Plan.PDAddrs...)

	addr := utils.JoinHostPort(inst.Host, inst.Port)
	args := []string{
		fmt.Sprintf("--pd.endpoints=%s", strings.Join(endpoints, ",")),
		fmt.Sprintf("--address=%s", addr),
		fmt.Sprintf("--advertise-address=%s", addr),
		fmt.Sprintf("--storage.path=%s", filepath.Join(inst.Dir, "data")),
		fmt.Sprintf("--log.path=%s", filepath.Join(inst.Dir, "logs")),
	}

	info.Proc = &cmdProcess{cmd: PrepareCommand(ctx, binPath, args, nil, inst.Dir)}
	return nil
}
