package alert

import (
	"encoding/json"
	"fmt"
	"regexp"
	"sort"
	"strings"

	"github.com/tidbcloud/alertstore/pkg/instance"
)

// Alert-level facts derived from an occurrence's labels: the fields a card, a
// Jira header or the detail page prints above the occurrence list, where they
// read as properties of the whole alert rather than of one occurrence.
const (
	FactClusterID   = "cluster_id"
	FactTenantID    = "tenant_id"
	FactRegion      = "region"
	FactComponent   = "component"
	FactNamespace   = "namespace"
	FactBizType     = "biz_type"
	FactEnvironment = "environment"
	FactVisibility  = "visibility"
)

// VisibilityExternal marks a rule whose notification goes to the customer.
// Alertstore receives a copy of those through the same Alertmanager feed as
// everything else, and they are the one case where the notification has
// already reached its audience before it reaches us: the customer was told
// directly, by cloud_server, on Alertmanager's own schedule.
//
// The label is set on the alert rule, which is the only place that knows who a
// rule is written for. Everything alertstore does with such an alert follows
// from it — see Classifier (never page on a customer's copy) and the notice
// delivery path.
const VisibilityExternal = "external"

// NormalizeVisibility derives the audience an occurrence's labels declare, in
// the one spelling everything downstream compares against and the one stored in
// attrs. Canonicalizing once here rather than at each comparison is what keeps
// a rule that wrote `External` from being classified as customer-facing by the
// classifier and as internal by delivery.
//
// Empty means internal: a rule that says nothing about its audience is one
// nobody outside has seen, which is the assumption that fails safe — treating
// an internal alert as external would silently stop paging for it.
func NormalizeVisibility(labels map[string]string) string {
	return strings.ToLower(Fact(FactVisibility, labels))
}

// factLabels lists the label keys each fact reads, in precedence order. It is
// the single source of truth for the derivation: ingestion builds AlertAttrs
// through Fact, and the renderers replay the same derivation across every
// occurrence through CommonFact. Cluster and tenant carry one more last-resort
// key each (namespace, o11y_tenant_id), applied in Fact only when these produce
// nothing, so it never overrides an explicit value.
var factLabels = map[string][]string{
	FactClusterID:   {"tidb_cluster_id", "cluster_id", "tidbcloud_cluster", "cluster"},
	FactTenantID:    {"tenant_id", "tenant"},
	FactRegion:      {"region", "o11y_region"},
	FactComponent:   {"component"},
	FactNamespace:   {"namespace"},
	FactBizType:     {"o11y_biz_type", "biz_type"},
	FactEnvironment: {"environment", "env"},
	FactVisibility:  {"visibility"},
}

var numericResourceID = regexp.MustCompile(`^(?:tidb)?(\d+)$`)

// Fact derives one alert-level fact from a single occurrence's label set.
func Fact(name string, labels map[string]string) string {
	switch name {
	case FactClusterID:
		if id := numericPreferred(labels, factLabels[name], true); id != "" {
			return id
		}
		// Last resort: a namespace of the form tidb<id> carries the cluster ID
		// when no cluster label was set. Only that form counts — namespace
		// otherwise names a pod namespace (kube-system) or a developer's own
		// (chenyiwen), neither of which is a cluster.
		if m := numericResourceID.FindStringSubmatch(strings.TrimSpace(labels["namespace"])); len(m) == 2 {
			return m[1]
		}
		return ""
	case FactTenantID:
		if id := numericPreferred(labels, factLabels[name], false); id != "" {
			return id
		}
		// Last resort: the o11y-namespaced tenant, used only when neither
		// tenant_id nor tenant was set, so it never overrides an explicit one.
		return strings.TrimSpace(labels["o11y_tenant_id"])
	default:
		return FirstNonEmptyLabel(labels, factLabels[name]...)
	}
}

// FirstNonEmptyLabel returns the first label carrying a value, in the order the
// keys are given.
func FirstNonEmptyLabel(labels map[string]string, keys ...string) string {
	for _, key := range keys {
		if value := strings.TrimSpace(labels[key]); value != "" {
			return value
		}
	}
	return ""
}

// numericPreferred picks the first key whose value looks like a resource ID,
// falling back to the first key carrying anything at all: several of these
// labels are also used for names, and the numeric form is the one that joins to
// clinic. extract strips the tidb prefix, which cluster IDs carry and tenant
// IDs do not.
func numericPreferred(labels map[string]string, keys []string, extract bool) string {
	var fallback string
	for _, key := range keys {
		value := strings.TrimSpace(labels[key])
		if value == "" {
			continue
		}
		if fallback == "" {
			fallback = value
		}
		if match := numericResourceID.FindStringSubmatch(value); len(match) == 2 {
			if extract {
				return match[1]
			}
			return value
		}
	}
	return fallback
}

// maxListedFactValues caps how many distinct values a divergent fact prints
// before the rest collapse into a count: a header line that grows with the
// aggregate stops being readable.
const maxListedFactValues = 3

// noFactValue marks occurrences that carry nothing for the fact, so a partially
// set label is not passed off as one every occurrence agrees on.
const noFactValue = "(none)"

// CommonFact renders a fact across an alert's occurrence set. Alertmanager only
// puts a label in commonLabels when every alert in the group carries the same
// value, and the same rule has to hold here: the aggregation key (AggLabels)
// does not cover component, region, tenant, biz type or environment, so
// occurrences folded into one alert can legitimately disagree on them. Printing
// whichever value belonged to the occurrence that opened the alert would pass
// it off as the aggregate's.
//
// fallback — the AlertAttrs value, frozen at creation — stands in when the
// occurrence set carries nothing for the fact, which is how clinic-enriched
// values survive: they never reach an occurrence's labels.
func CommonFact(name string, instances []instance.Instance, fallback string) string {
	var values []string
	seen := make(map[string]bool, 2)
	for i := range instances {
		value := Fact(name, decodeLabels(instances[i].Labels))
		if seen[value] {
			continue
		}
		seen[value] = true
		values = append(values, value)
	}

	if len(values) == 0 || (len(values) == 1 && values[0] == "") {
		return fallback
	}
	if len(values) == 1 {
		return values[0]
	}

	sort.Strings(values)
	extra := 0
	if len(values) > maxListedFactValues {
		extra = len(values) - maxListedFactValues
		values = values[:maxListedFactValues]
	}
	rendered := make([]string, len(values))
	for i, value := range values {
		if value == "" {
			value = noFactValue
		}
		rendered[i] = value
	}
	out := strings.Join(rendered, ", ")
	if extra > 0 {
		out += fmt.Sprintf(", +%d more", extra)
	}
	return out
}

// CommonFacts renders every label-derived fact over the occurrence set, for
// consumers that lay out the header themselves (the web UI through the alert
// detail API). Facts that resolve to nothing are omitted, so a caller can fall
// back to the frozen attrs value by simple lookup.
func CommonFacts(instances []instance.Instance, attrs *AlertAttrs) map[string]string {
	fallbacks := map[string]string{
		FactClusterID:   attrs.ClusterID,
		FactTenantID:    attrs.TenantID,
		FactRegion:      attrs.Region,
		FactComponent:   attrs.Component,
		FactNamespace:   attrs.Namespace,
		FactBizType:     attrs.BizType,
		FactEnvironment: attrs.Environment,
	}
	facts := make(map[string]string, len(fallbacks))
	for name, fallback := range fallbacks {
		if value := CommonFact(name, instances, fallback); value != "" {
			facts[name] = value
		}
	}
	return facts
}

// alertLevelLabels names the labels that describe the alert rather than the
// occurrence carrying them, for the single-occurrence case where the
// intersection cannot tell the two apart (see CommonLabels).
//
// It is derived from factLabels, not hand-listed: every label a fact reads is
// alert-level, so the single occurrence's distinct block does not keep fleet-
// wide context (cluster_id, region, environment) as if it distinguished that
// one occurrence. A hand-list drifted from factLabels and left exactly those
// behind. Plus the fixed context labels that are not facts, and o11y_tenant_id,
// the one last-resort tenant key Fact reads outside factLabels.
var alertLevelLabels = buildAlertLevelLabels()

func buildAlertLevelLabels() map[string]bool {
	m := map[string]bool{
		"alertgroup":     true,
		"alertname":      true,
		"oncall_team":    true,
		"severity":       true,
		"tier":           true,
		"o11y_tenant_id": true,
	}
	for _, keys := range factLabels {
		for _, key := range keys {
			m[key] = true
		}
	}
	return m
}

// CommonLabels returns the labels every occurrence carries with the same value:
// alertmanager's commonLabels, recomputed over the alert's own occurrence set
// because that set is not one of its notification groups.
//
// It is what lets a reader see an occurrence's own labels — the ones that say
// which pod, which disk, which store — instead of re-reading the fleet-wide
// context on every row. Subtract it from an occurrence's labels to get those:
// see DistinctLabels.
//
// One occurrence agrees with itself on everything, so the intersection would
// hand the whole label set to the alert and leave the occurrence with nothing —
// exactly backwards for the alerts that are read most. There the split falls
// back to alertLevelLabels, which names the fleet-wide context outright.
func CommonLabels(instances []instance.Instance) map[string]string {
	if len(instances) == 1 {
		labels := decodeLabels(instances[0].Labels)
		common := make(map[string]string, len(alertLevelLabels))
		for key, value := range labels {
			if alertLevelLabels[key] {
				common[key] = value
			}
		}
		return common
	}

	var common map[string]string
	for i := range instances {
		labels := decodeLabels(instances[i].Labels)
		if common == nil {
			common = make(map[string]string, len(labels))
			for key, value := range labels {
				common[key] = value
			}
			continue
		}
		for key, value := range common {
			if other, ok := labels[key]; !ok || other != value {
				delete(common, key)
			}
		}
	}
	return common
}

// DistinctLabels returns what one occurrence carries beyond the common set. A
// key held by every occurrence at a different value stays: it is precisely what
// tells them apart.
func DistinctLabels(inst *instance.Instance, common map[string]string) map[string]string {
	labels := decodeLabels(inst.Labels)
	distinct := make(map[string]string, len(labels))
	for key, value := range labels {
		if common[key] == value {
			continue
		}
		distinct[key] = value
	}
	return distinct
}

func decodeLabels(raw json.RawMessage) map[string]string {
	var labels map[string]string
	_ = json.Unmarshal(raw, &labels)
	return labels
}
