package subscription

import (
	"bytes"
	"encoding/json"
	"fmt"
	"regexp"
	"strings"
	"sync"
	"time"
)

// Delivery modes. A mode says what a subscription is about, not merely how it
// arrives — each names a different thing to be told about, delivered in its own
// unit:
//
//	topic_forward  a human alert, as a pointer into its topic (replies sync back)
//	card_only      a human alert, as a standalone card
//	event          an observation that never became an alert, one card per event
//	customer       a notification the customer already received, one card per event
//
// The split is what makes each one opt-in. The first two are alerts somebody is
// expected to act on; the last two are things nobody was paged for, arriving at
// Alertmanager's notification rate rather than once per alert. A channel that
// wants the quiet ones asks for them by mode, and no filter left empty can
// hand it a firehose it did not choose.
const (
	ModeTopicForward = "topic_forward"
	ModeCardOnly     = "card_only"
	ModeEvent        = "event"
	ModeCustomer     = "customer"
)

// AlertModes deliver a human alert; EventModes deliver one card per stored
// event. Which of the two a mode belongs to decides everything downstream: the
// unit of delivery, whether there is an alert to attach to, and whether a
// classification filter is even meaningful.
var (
	AlertModes = []string{ModeTopicForward, ModeCardOnly}
	EventModes = []string{ModeEvent, ModeCustomer}
)

type Subscription struct {
	ID          string          `db:"id" json:"id" validate:"required"`
	ChannelID   string          `db:"channel_id" json:"channel_id" validate:"required"`
	ChannelName string          `db:"channel_name" json:"channel_name" validate:"required"`
	Mode        string          `db:"mode" json:"mode" validate:"required"` // topic_forward / card_only / event / customer
	Filter      json.RawMessage `db:"filter" json:"filter" swaggertype:"object" validate:"required"`
	CreatedBy   string          `db:"created_by" json:"created_by" validate:"required"`
	CreatedAt   time.Time       `db:"created_at" json:"created_at" validate:"required"`
	UpdatedAt   time.Time       `db:"updated_at" json:"updated_at" validate:"required"`
}

// The match operators, spelled as alertmanager spells them. Equal and its
// negation compare a value; the regex pair matches a whole-string pattern. An
// empty operator on the wire is read as OpEq, which is what a bare array — every
// field's shape before operators existed — decodes to.
const (
	OpEq  = "="
	OpNe  = "!="
	OpRe  = "=~"
	OpNre = "!~"
)

// FieldMatch is one field's matcher: an operator over a set of alternative
// values. It is the alertmanager matcher read for one label rather than typed
// into a query — `alertname =~ (TiKV_.*|PD_.*)` is Op OpRe over two values.
//
// On the wire the exact-equal case is a bare array of strings, the shape every
// field had before operators existed, so a filter written then parses and
// round-trips to the same bytes. Any other operator is the object
// {"op": "...", "values": [...]}.
type FieldMatch struct {
	Op     string
	Values []string
	res    []*regexp.Regexp
}

// Eq builds an exact-equal matcher, for filters assembled in code rather than
// decoded from a request — the shape every field held before operators existed.
func Eq(values ...string) *FieldMatch { return &FieldMatch{Op: OpEq, Values: values} }

func (m *FieldMatch) isRegexOp() bool { return m.Op == OpRe || m.Op == OpNre }

// empty reports a matcher that constrains nothing — unset, or present with no
// values — so it reads the same as a field the filter never named.
func (m *FieldMatch) empty() bool { return m == nil || len(m.Values) == 0 }

// regexCache compiles each distinct pattern once. A filter is unmarshaled on
// every match check — IsMuted reads every active rule per alert — so compiling
// in UnmarshalJSON without a cache would recompile the same handful of patterns
// thousands of times a day. The set of patterns is bounded by the rules that
// exist, so the cache never needs eviction.
var regexCache sync.Map // pattern string -> compiled

type compiled struct {
	re  *regexp.Regexp
	err error
}

// compileMatch anchors the pattern to the whole string, the way alertmanager's
// `=~` does: a filter value describes the whole label it matches, not a
// substring of it, so `TiKV_.*` means an alertname that starts with `TiKV_`
// rather than one that contains it.
func compileMatch(pattern string) (*regexp.Regexp, error) {
	if v, ok := regexCache.Load(pattern); ok {
		c := v.(compiled)
		return c.re, c.err
	}
	re, err := regexp.Compile("^(?:" + pattern + ")$")
	if err != nil {
		err = fmt.Errorf("invalid regex %q: %w", pattern, err)
	}
	regexCache.Store(pattern, compiled{re: re, err: err})
	return re, err
}

// compile fills in the compiled regexps for a regex operator, and reports the
// first pattern that will not parse. Called where the filter is decoded, so a
// bad pattern is a create-time error rather than a rule that skips itself when
// it is next matched.
func (m *FieldMatch) compile() error {
	m.res = nil
	if !m.isRegexOp() {
		return nil
	}
	m.res = make([]*regexp.Regexp, len(m.Values))
	for i, v := range m.Values {
		re, err := compileMatch(v)
		if err != nil {
			return err
		}
		m.res[i] = re
	}
	return nil
}

// UnmarshalJSON accepts the two shapes a field can take: a bare array is an
// exact-equal matcher, an object carries an explicit operator.
func (m *FieldMatch) UnmarshalJSON(data []byte) error {
	if t := bytes.TrimSpace(data); len(t) > 0 && t[0] == '[' {
		if err := json.Unmarshal(data, &m.Values); err != nil {
			return err
		}
		m.Op = OpEq
		return m.compile()
	}
	var obj struct {
		Op     string   `json:"op"`
		Values []string `json:"values"`
	}
	if err := json.Unmarshal(data, &obj); err != nil {
		return err
	}
	m.Op, m.Values = obj.Op, obj.Values
	if m.Op == "" {
		m.Op = OpEq
	}
	switch m.Op {
	case OpEq, OpNe, OpRe, OpNre:
	default:
		return fmt.Errorf("unsupported match operator %q", obj.Op)
	}
	return m.compile()
}

// MarshalJSON writes an exact-equal matcher back as the bare array it came in
// as, so a filter round-trips to the same bytes and a reader that predates
// operators still understands the equal case. Any other operator writes as its
// object.
func (m FieldMatch) MarshalJSON() ([]byte, error) {
	if m.Op == "" || m.Op == OpEq {
		return json.Marshal(m.Values)
	}
	return json.Marshal(struct {
		Op     string   `json:"op"`
		Values []string `json:"values"`
	}{m.Op, m.Values})
}

// matches reports whether target satisfies the field. An unset field matches
// everything, so a filter constrains only what it names. fold folds case for
// the enum fields, on the equal operators only — a regex carries its own casing.
//
// Negation flips the whole set, not each value: `!=` over (a, b) means the
// target is neither a nor b, and `!~` means it matches none of the patterns.
func (m *FieldMatch) matches(target string, fold bool) bool {
	if m.empty() {
		return true
	}
	switch m.Op {
	case OpEq, "":
		return anyEqual(m.Values, target, fold)
	case OpNe:
		return !anyEqual(m.Values, target, fold)
	case OpRe:
		return anyRegex(m.res, target)
	case OpNre:
		return !anyRegex(m.res, target)
	}
	return false
}

func anyEqual(values []string, target string, fold bool) bool {
	for _, v := range values {
		if fold {
			if strings.EqualFold(v, target) {
				return true
			}
		} else if v == target {
			return true
		}
	}
	return false
}

func anyRegex(res []*regexp.Regexp, target string) bool {
	for _, re := range res {
		if re != nil && re.MatchString(target) {
			return true
		}
	}
	return false
}

// SubscriptionFilter is the parsed form of the filter JSON. It narrows within
// a mode; it never crosses one. Who an alert was written for is not a filter
// field, because it is the mode: an external alert reaches customer
// subscriptions and nothing else.
//
// Each field is a pointer so an unnamed one is omitted from the wire rather
// than written as an empty matcher, and so Matches can tell "not named" from
// "named with no values".
type SubscriptionFilter struct {
	Region         *FieldMatch `json:"region,omitempty"`
	Classification *FieldMatch `json:"classification,omitempty"`
	Alertname      *FieldMatch `json:"alertname,omitempty"`
	ClusterID      *FieldMatch `json:"cluster_id,omitempty"`
	TenantID       *FieldMatch `json:"tenant_id,omitempty"`
	// These three narrow by whose account the alert belongs to rather than where
	// it runs: the sales region (NA/EMEA/APAC/…), the internal/external split,
	// and the sales tier (SKA/KA/SMB), all enriched from the ops portal. Region
	// above stays the cloud region — a team covering EMEA customers is not the
	// same set of alerts as one covering eu-central-1.
	//
	// They are matched case-insensitively, unlike every field beside them: they
	// come from enums whose spelling differs by field (`NA`, `internal`, `KA`),
	// and a filter written `Internal` that silently matched nothing would be
	// indistinguishable from one whose alerts simply had not fired.
	BizRegion  *FieldMatch `json:"biz_region,omitempty"`
	TenantKind *FieldMatch `json:"tenant_kind,omitempty"`
	// CustomerType is the ops portal's segmentation under the name alertstore
	// gave it when the field was designed, years before it had a source. Until
	// the portal lookup arrived nothing populated it, so a subscription naming
	// it matched nothing at all.
	CustomerType *FieldMatch `json:"customer_type,omitempty"`
	Component    *FieldMatch `json:"component,omitempty"`
	// ObservationType narrows by how the observation relates to its
	// occurrence: first_firing, repeat_firing, or resolved. Only meaningful
	// for event-mode subscriptions, which deliver one card per Alertmanager
	// notification; alert modes and mute rules reject it at validation.
	ObservationType *FieldMatch `json:"observation_type,omitempty"`
	// Labels matches the alert's raw alertmanager labels, one entry per label
	// name. The fields above are promoted attrs — normalized, and sometimes
	// assembled from labels that are not named after them — so they cannot
	// stand in for a label the alert rules happen to carry. `oncall_team` is
	// the case that motivated this and has no promoted attr at all: a team
	// filtering by ownership is filtering on a label.
	//
	// A name the alert does not carry matches the empty string, which is what
	// makes `!=` and `!~` reach an alert missing the label, the way alertmanager
	// treats an absent label as blank.
	Labels map[string]*FieldMatch `json:"labels,omitempty"`
}

// Target is the alert context a filter is matched against. A struct rather
// than a parameter list because every field is a string and several are
// plausible values for each other — cluster and tenant IDs are both bare
// numbers — so an argument transposed at one call site would match the wrong
// subscriptions without failing to compile.
type Target struct {
	Region          string
	Classification  string
	Alertname       string
	ClusterID       string
	TenantID        string
	BizRegion       string
	TenantKind      string
	CustomerType    string
	Component       string
	ObservationType string
	// Labels is the alert's label set as alertmanager sent it, with nothing
	// derived and nothing defaulted. `oncall_team` in particular is the bare
	// label: the default team belongs to assignment, which reads the oncall
	// config, and the mute check and the event fan-out do not carry it — a
	// fallback only some paths could apply would make one rule match in one
	// place and not the other.
	Labels map[string]string
}

// Matches reports whether the target satisfies every field of this filter. An
// empty field matches everything, so a filter constrains only what it names.
func (f *SubscriptionFilter) Matches(t Target) bool {
	return f.Region.matches(t.Region, false) &&
		f.Classification.matches(t.Classification, false) &&
		f.Alertname.matches(t.Alertname, false) &&
		f.ClusterID.matches(t.ClusterID, false) &&
		f.TenantID.matches(t.TenantID, false) &&
		f.BizRegion.matches(t.BizRegion, true) &&
		f.TenantKind.matches(t.TenantKind, true) &&
		f.CustomerType.matches(t.CustomerType, true) &&
		f.Component.matches(t.Component, false) &&
		f.ObservationType.matches(t.ObservationType, false) &&
		f.matchLabels(t.Labels)
}

// fields is the promoted matchers keyed by their wire name — the one place they
// are enumerated, so validation and emptiness ask about them by name without a
// second list to keep in step. Labels are left out: they are a map already.
func (f *SubscriptionFilter) fields() map[string]*FieldMatch {
	return map[string]*FieldMatch{
		"region":           f.Region,
		"classification":   f.Classification,
		"alertname":        f.Alertname,
		"cluster_id":       f.ClusterID,
		"tenant_id":        f.TenantID,
		"biz_region":       f.BizRegion,
		"tenant_kind":      f.TenantKind,
		"customer_type":    f.CustomerType,
		"component":        f.Component,
		"observation_type": f.ObservationType,
	}
}

// IsEmpty reports a filter that names nothing and so matches everything — said
// out loud rather than left to be inferred from a large count.
func (f *SubscriptionFilter) IsEmpty() bool {
	for _, m := range f.fields() {
		if !m.empty() {
			return false
		}
	}
	for _, m := range f.Labels {
		if !m.empty() {
			return false
		}
	}
	return true
}

// matchLabels requires every label the filter names, so adding a name narrows
// the filter the way adding a field does.
func (f *SubscriptionFilter) matchLabels(labels map[string]string) bool {
	for name, m := range f.Labels {
		if !m.matches(labels[name], false) {
			return false
		}
	}
	return true
}
