package cmd

import (
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"time"

	"github.com/mattn/go-isatty"
	"github.com/spf13/cobra"
	"github.com/tidbcloud/clinic-cli/internal/clinic"
	"github.com/tidbcloud/clinic-cli/internal/cmd/metricecharts"
	"github.com/tidbcloud/clinic-cli/internal/cmd/metricstui"
)

const (
	defaultMetricsRangeStep         = "15s"
	metricsGatewayTimeoutMaxRetries = lokiGatewayTimeoutMaxRetries
)

func metricsCommandHandler(ctx *RuntimeContext, args []string, stdout io.Writer) error {
	command := newMetricsCommand(staticRuntime(ctx), stdout)
	if err := executeCobraCommand(command, args); err != nil {
		return normalizeFamilyCommandError(err, "metrics")
	}
	return nil
}

func newMetricsCommand(runtime runtimeResolver, stdout io.Writer) *cobra.Command {
	command := newCommand(commandMetadata{
		Use:   "metrics",
		Short: "Run Prometheus instant, range, and series queries.",
		Long: `Run Prometheus-compatible metrics queries.

Subcommands:
  query  Run an instant PromQL query.
  range  Run a range PromQL query.
  series Query series metadata.`,
		Example: `  clinic metrics query --cluster-id cluster-1 --query up
  clinic metrics range --cluster-id cluster-1 --query up --start now-5m --end now
  clinic metrics series --cluster-id cluster-1 --match '{__name__="up"}'`,
	}, stdout)
	command.Args = func(_ *cobra.Command, args []string) error {
		return rejectUnexpectedArgs(args)
	}
	command.RunE = func(c *cobra.Command, _ []string) error {
		return c.Help()
	}
	command.AddCommand(
		newMetricsQueryCommand(runtime, stdout),
		newMetricsRangeCommand(runtime, stdout),
		newMetricsSeriesCommand(runtime, stdout),
	)
	return command
}

type metricsQueryOptions struct {
	ClusterID string
	Pool      bool
	Query     string
	Time      string
	Timeout   string
}

func newMetricsQueryCommand(runtime runtimeResolver, stdout io.Writer) *cobra.Command {
	var opts metricsQueryOptions
	command := newCommand(commandMetadata{
		Use:   "query --cluster-id <cluster-id> --query <promql>",
		Short: "Run an instant PromQL query.",
		Long: `Run an instant PromQL query.

Behavior:
  --cluster-id can be provided by flag, CLINIC_CLUSTER_ID, or selected profile defaults.
  --pool, -p queries shared resource-pool metrics via parent_id for premium clusters only.

Time formats:
  --time accepts datetime with optional timezone or epoch in s/ms/ns.`,
		Example: "  clinic metrics query --cluster-id cluster-1 --query up --time now",
	}, stdout)
	command.Args = func(_ *cobra.Command, args []string) error {
		return rejectUnexpectedArgs(args)
	}
	command.Flags().StringVar(&opts.ClusterID, "cluster-id", "", "Cluster ID. Or set CLINIC_CLUSTER_ID, or selected profile cluster_id when a profile is active.")
	command.Flags().BoolVarP(&opts.Pool, "pool", "p", false, "Query shared resource-pool metrics via parent_id (premium clusters only).")
	command.Flags().StringVar(&opts.Query, "query", "", "PromQL query.")
	command.Flags().StringVar(&opts.Time, "time", "", "Evaluation timestamp. Datetime timezone is optional and defaults to local; or use epoch in s/ms/ns.")
	command.Flags().StringVar(&opts.Timeout, "timeout", "", "Server-side Prometheus timeout (query parameter, not client HTTP timeout).")
	command.RunE = func(_ *cobra.Command, _ []string) error {
		ctx, err := runtime()
		if err != nil {
			return err
		}
		return runMetricsQuery(ctx, opts, stdout)
	}
	return command
}

func runMetricsQuery(ctx *RuntimeContext, opts metricsQueryOptions, stdout io.Writer) error {
	resolvedClusterID, err := requiredFlagOrEnvWithContext(ctx, "cluster-id", opts.ClusterID, envClinicClusterID)
	if err != nil {
		return err
	}
	if opts.Query == "" {
		return errors.New("missing required --query")
	}

	metricsClusterID, err := resolveMetricsTargetClusterID(ctx, resolvedClusterID, opts.Pool)
	if err != nil {
		return err
	}

	params := url.Values{}
	params.Set("query", opts.Query)
	resolvedTime, err := parseOptionalTimeFlagToUnixSeconds("time", opts.Time)
	if err != nil {
		return err
	}
	addOptionalString(params, "time", resolvedTime)
	addOptionalString(params, "timeout", opts.Timeout)

	return executeMetricsJSONWithRetry(ctx, clinic.Request{
		Method: http.MethodGet,
		Path:   fmt.Sprintf("/data-proxy/metrics/%s/api/v1/query", metricsClusterID),
		Query:  params,
	}, stdout)
}

type metricsRangeOptions struct {
	ClusterID  string
	Pool       bool
	DataServer bool
	Query      string
	Start      string
	End        string
	Step       string
	Timeout    string
	Output     string
}

func newMetricsRangeCommand(runtime runtimeResolver, stdout io.Writer) *cobra.Command {
	var opts metricsRangeOptions
	command := newCommand(commandMetadata{
		Use:   "range --cluster-id <cluster-id> --query <promql> --start <start> --end <end>",
		Short: "Run a range PromQL query.",
		Long: `Run a range PromQL query.

Behavior:
  Default output is ECharts.
  --data-server or --ds routes requests to /clinic/api/v1/data/metrics for node-level metrics such as node_*.
  --data-server cannot be used with --pool.
  ECharts writes an interactive HTML report and attempts to open it in a browser.
  Use tui only when stdout is a terminal.

Time formats:
  --start/--end accept now, now-<duration>, datetime with optional timezone, or epoch in s/ms/ns.`,
		Example: "  clinic metrics range --cluster-id cluster-1 --query up --start now-5m --end now --output json",
	}, stdout)
	command.Args = func(_ *cobra.Command, args []string) error {
		return rejectUnexpectedArgs(args)
	}
	command.Flags().StringVar(&opts.ClusterID, "cluster-id", "", "Cluster ID. Or set CLINIC_CLUSTER_ID, or selected profile cluster_id when a profile is active.")
	command.Flags().BoolVarP(&opts.Pool, "pool", "p", false, "Query shared resource-pool metrics via parent_id (premium clusters only).")
	command.Flags().BoolVar(&opts.DataServer, "data-server", false, "Route to /clinic/api/v1/data/metrics for node-level metrics such as node_*.")
	command.Flags().BoolVar(&opts.DataServer, "ds", false, "Alias for --data-server.")
	command.Flags().StringVar(&opts.Query, "query", "", "PromQL query.")
	command.Flags().StringVar(&opts.Start, "start", "", "Range start. Or set CLINIC_RANGE_START, or selected profile range_start when a profile is active.")
	command.Flags().StringVar(&opts.End, "end", "", "Range end. Or set CLINIC_RANGE_END, or selected profile range_end when a profile is active.")
	command.Flags().StringVar(&opts.Step, "step", "", fmt.Sprintf("Range step. Default: %s.", defaultMetricsRangeStep))
	command.Flags().StringVar(&opts.Timeout, "timeout", "", "Server-side Prometheus timeout (query parameter, not client HTTP timeout).")
	command.Flags().StringVarP(&opts.Output, "output", "o", "", "Output format. Supported: echarts, json, tui. Default: echarts. ECharts writes an interactive HTML report and attempts to open it in a browser. Use tui only when stdout is a terminal.")
	command.RunE = func(_ *cobra.Command, _ []string) error {
		ctx, err := runtime()
		if err != nil {
			return err
		}
		return runMetricsRange(ctx, opts, stdout)
	}
	return command
}

func runMetricsRange(ctx *RuntimeContext, opts metricsRangeOptions, stdout io.Writer) error {
	return runMetricsRangeWithHooks(ctx, opts, stdout, metricsRangeWriterIsTTY, metricstui.RunRangeTUI, metricecharts.RunRangeEChartsWithContext)
}

func runMetricsRangeWithHooks(
	ctx *RuntimeContext,
	opts metricsRangeOptions,
	stdout io.Writer,
	isTerminal func(io.Writer) bool,
	runRangeTUI func(io.Writer, []byte) error,
	runRangeECharts func(io.Writer, []byte, metricecharts.RangeReportContext) error,
) error {
	resolvedClusterID, err := requiredFlagOrEnvWithContext(ctx, "cluster-id", opts.ClusterID, envClinicClusterID)
	if err != nil {
		return err
	}
	if opts.Query == "" {
		return errors.New("missing required --query")
	}
	resolvedStart, resolvedEnd, err := resolveRequiredRangeWithContext(ctx, "start", opts.Start, "end", opts.End)
	if err != nil {
		return err
	}
	if opts.Output != "" && opts.Output != "json" && opts.Output != "tui" && opts.Output != "echarts" {
		return errors.New("output must be one of: json, tui, echarts")
	}
	if opts.DataServer && opts.Pool {
		return errors.New("--data-server cannot be used with --pool")
	}
	if isTerminal == nil {
		isTerminal = metricsRangeWriterIsTTY
	}
	if runRangeTUI == nil {
		runRangeTUI = metricstui.RunRangeTUI
	}
	if runRangeECharts == nil {
		runRangeECharts = metricecharts.RunRangeEChartsWithContext
	}
	resolvedStep := opts.Step
	if resolvedStep == "" {
		resolvedStep = defaultMetricsRangeStep
	}

	params := url.Values{}
	startTime, err := parseTimeFlagToUnixSeconds("start", resolvedStart)
	if err != nil {
		return err
	}
	endTime, err := parseTimeFlagToUnixSeconds("end", resolvedEnd)
	if err != nil {
		return err
	}
	params.Set("query", opts.Query)
	params.Set("start", startTime)
	params.Set("end", endTime)
	params.Set("step", resolvedStep)
	addOptionalString(params, "timeout", opts.Timeout)

	req := clinic.Request{
		Method: http.MethodGet,
		Query:  params,
	}
	if opts.DataServer {
		clusterRef, err := resolveClusterReferenceByID(ctx, resolvedClusterID)
		if err != nil {
			return err
		}

		req.Path = "/clinic/api/v1/data/metrics"
		req.Headers = http.Header{
			"X-OrgType":   []string{"cloud"},
			"X-OrgID":     []string{clusterRef.OrgID},
			"X-ClusterID": []string{resolvedClusterID},
		}
	} else {
		metricsClusterID, err := resolveMetricsTargetClusterID(ctx, resolvedClusterID, opts.Pool)
		if err != nil {
			return err
		}
		req.Path = fmt.Sprintf("/data-proxy/metrics/%s/api/v1/query_range", metricsClusterID)
	}

	isTTY := isTerminal(stdout)
	if opts.Output == "tui" && !isTTY {
		return errors.New("output tui requires a TTY")
	}

	body, err := executeMetricsRawWithRetry(ctx, req)
	if err != nil {
		return err
	}

	switch {
	case opts.Output == "json":
		return writeFormattedJSONOrRaw(stdout, body)
	case opts.Output == "tui":
		return runRangeTUI(stdout, body)
	default:
		reportContext := buildMetricsRangeReportContext(ctx, resolvedClusterID, opts.Query)
		return runRangeECharts(stdout, body, reportContext)
	}
}

func buildMetricsRangeReportContext(ctx *RuntimeContext, clusterID, query string) metricecharts.RangeReportContext {
	reportContext := metricecharts.RangeReportContext{
		ClusterID:    clusterID,
		Query:        query,
		TimeLocation: time.Local,
	}

	if clusterID == "" {
		return reportContext
	}

	clusterRef, err := resolveClusterReferenceByIDWithMetadata(ctx, clusterID)
	if err != nil {
		diagnosticf(ctx, "metrics range echarts metadata lookup skipped: %v", err)
		return reportContext
	}

	if clusterRef.ClusterID != "" {
		reportContext.ClusterID = clusterRef.ClusterID
	}
	reportContext.ClusterName = clusterRef.ClusterName
	reportContext.DeployType = clusterRef.DeployType()
	return reportContext
}

type metricsSeriesOptions struct {
	ClusterID string
	Pool      bool
	Matches   stringSliceFlag
	Start     string
	End       string
}

func newMetricsSeriesCommand(runtime runtimeResolver, stdout io.Writer) *cobra.Command {
	var opts metricsSeriesOptions
	command := newCommand(commandMetadata{
		Use:   "series --cluster-id <cluster-id> --match <selector>",
		Short: "Query series metadata.",
		Long: `Query series metadata.

Behavior:
  --cluster-id can be provided by flag, CLINIC_CLUSTER_ID, or selected profile defaults.
  --match is repeatable.

Time formats:
  --start/--end accept datetime with optional timezone or epoch in s/ms/ns.`,
	}, stdout)
	command.Args = func(_ *cobra.Command, args []string) error {
		return rejectUnexpectedArgs(args)
	}
	command.Flags().StringVar(&opts.ClusterID, "cluster-id", "", "Cluster ID. Or set CLINIC_CLUSTER_ID, or selected profile cluster_id when a profile is active.")
	command.Flags().BoolVarP(&opts.Pool, "pool", "p", false, "Query shared resource-pool metrics via parent_id (premium clusters only).")
	command.Flags().Var(&opts.Matches, "match", "Series selector (repeatable).")
	command.Flags().StringVar(&opts.Start, "start", "", "Range start. Or set CLINIC_RANGE_START, or selected profile range_start when a profile is active.")
	command.Flags().StringVar(&opts.End, "end", "", "Range end. Or set CLINIC_RANGE_END, or selected profile range_end when a profile is active.")
	command.RunE = func(_ *cobra.Command, _ []string) error {
		ctx, err := runtime()
		if err != nil {
			return err
		}
		return runMetricsSeries(ctx, opts, stdout)
	}
	return command
}

func runMetricsSeries(ctx *RuntimeContext, opts metricsSeriesOptions, stdout io.Writer) error {
	resolvedClusterID, err := requiredFlagOrEnvWithContext(ctx, "cluster-id", opts.ClusterID, envClinicClusterID)
	if err != nil {
		return err
	}
	if len(opts.Matches) == 0 {
		return errors.New("missing required --match")
	}
	metricsClusterID, err := resolveMetricsTargetClusterID(ctx, resolvedClusterID, opts.Pool)
	if err != nil {
		return err
	}

	params := url.Values{}
	resolvedStart := optionalFlagOrEnvWithContext(ctx, opts.Start, envClinicRangeStart)
	startTime, err := parseOptionalTimeFlagToUnixSeconds("start", resolvedStart)
	if err != nil {
		return err
	}
	resolvedEnd := optionalFlagOrEnvWithContext(ctx, opts.End, envClinicRangeEnd)
	endTime, err := parseOptionalTimeFlagToUnixSeconds("end", resolvedEnd)
	if err != nil {
		return err
	}
	for _, match := range opts.Matches {
		params.Add("match[]", match)
	}
	addOptionalString(params, "start", startTime)
	addOptionalString(params, "end", endTime)

	return executeMetricsJSONWithRetry(ctx, clinic.Request{
		Method: http.MethodGet,
		Path:   fmt.Sprintf("/data-proxy/metrics/%s/api/v1/series", metricsClusterID),
		Query:  params,
	}, stdout)
}

func resolveMetricsTargetClusterID(ctx *RuntimeContext, clusterID string, pool bool) (string, error) {
	if !pool {
		return clusterID, nil
	}

	clusterRef, err := resolveClusterReferenceByIDWithMetadata(ctx, clusterID)
	if err != nil {
		return "", err
	}

	if deployType := clusterRef.DeployType(); deployType != "premium" {
		if deployType == "" {
			deployType = "unknown"
		}
		return "", fmt.Errorf(
			"--pool is only supported for premium clusters with parent_id: %s (deploy type: %s)",
			clusterRef.ClusterID,
			deployType,
		)
	}

	if clusterRef.ParentID == "" || clusterRef.ParentID == "null" {
		return "", fmt.Errorf("premium cluster has no parentID: %s", clusterRef.ClusterID)
	}

	return clusterRef.ParentID, nil
}

func executeMetricsJSONWithRetry(ctx *RuntimeContext, req clinic.Request, stdout io.Writer) error {
	body, err := executeMetricsRawWithRetry(ctx, req)
	if err != nil {
		return err
	}

	return writeFormattedJSONOrRaw(stdout, body)
}

func executeMetricsRawWithRetry(ctx *RuntimeContext, req clinic.Request) ([]byte, error) {
	maxAttempts := metricsGatewayTimeoutMaxRetries + 1
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		var body []byte
		if err := doClinicRequest(ctx, req, &body); err != nil {
			if !isGatewayTimeoutError(err) || attempt == maxAttempts {
				return nil, err
			}

			diagnosticf(ctx, "metrics request received 504 Gateway Timeout, retrying attempt %d/%d", attempt+1, maxAttempts)
			continue
		}

		return body, nil
	}

	return nil, errors.New("unreachable metrics retry loop")
}

type stringSliceFlag []string

func (s *stringSliceFlag) String() string {
	return fmt.Sprint([]string(*s))
}

func (s *stringSliceFlag) Set(value string) error {
	if value == "" {
		return errors.New("flag value must not be empty")
	}
	*s = append(*s, value)
	return nil
}

func (s *stringSliceFlag) Type() string {
	return "stringSlice"
}

func parseTimeFlagToUnixSeconds(flagName, value string) (string, error) {
	parsed, err := parseFlexibleTimeToUnixNano(value, time.Local)
	if err != nil {
		return "", fmt.Errorf("invalid --%s: %w", flagName, err)
	}
	return strconv.FormatInt(parsed/int64(time.Second), 10), nil
}

func parseOptionalTimeFlagToUnixSeconds(flagName, value string) (string, error) {
	if value == "" {
		return "", nil
	}
	return parseTimeFlagToUnixSeconds(flagName, value)
}

type terminalWriter interface {
	IsTerminal() bool
}

func metricsRangeWriterIsTTY(w io.Writer) bool {
	if terminal, ok := w.(terminalWriter); ok {
		return terminal.IsTerminal()
	}

	fileDescriptor, ok := w.(writerFileDescriptor)
	if !ok {
		return false
	}

	fd := fileDescriptor.Fd()
	return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)
}
