// Package alert defines the Alert v1 wire shape shared by all ingest
// transports (HTTP, WebSocket, MQTT, gRPC) and the internal broker
// subject alerts.<company_id>.
//
// Validation is strict: unknown fields rejected, severity enum checked,
// dedupe_key length capped. See SPEC §4 + §22 layer 5.
package alert

import (
	"errors"
	"fmt"
	"regexp"
	"time"
)

// Severity is the alert severity taxonomy shared with the Android app.
// See SPEC §4.
type Severity string

const (
	SeverityInfo            Severity = "info"
	SeverityWarning         Severity = "warning"
	SeverityCritical        Severity = "critical"
	SeverityInminentColapse Severity = "inminent_colapse"
)

// ValidSeverities is the source of truth for the validation enum.
var ValidSeverities = map[Severity]struct{}{
	SeverityInfo:            {},
	SeverityWarning:         {},
	SeverityCritical:        {},
	SeverityInminentColapse: {},
}

// InminentColapseBypass is the only severity that bypasses quiet hours.
func (s Severity) InminentColapseBypass() bool {
	return s == SeverityInminentColapse
}

// Rank returns a numeric severity (0..3) used for `min_severity`
// comparisons in subscriptions. Returns -1 for unknown severities
// so the resolver can drop them defensively.
func (s Severity) Rank() int {
	switch s {
	case SeverityInfo:
		return 0
	case SeverityWarning:
		return 1
	case SeverityCritical:
		return 2
	case SeverityInminentColapse:
		return 3
	}
	return -1
}

// MinSeverityRank returns the rank of a min_severity string from
// the subscriptions table. Returns 0 for empty (info-and-above).
func MinSeverityRank(minSev string) int {
	if minSev == "" {
		return 0
	}
	return Severity(minSev).Rank()
}

// WireCaps (SPEC §22 layer 1) — anything bigger is rejected before
// validation runs. These match the per-source `max_payload_bytes`
// default of 256 KB and the per-field caps below.
const (
	MaxAlertIDLen      = 64
	MaxCompanyIDLen    = 64
	MaxSourceIDLen     = 64
	MaxSeverityLen     = 32
	MaxCategoryLen     = 64
	MaxTitleLen        = 256
	MaxBodyLen         = 4096
	MaxDedupeKeyLen    = 128
	MaxDataKeys        = 64
	MaxDataKeyLen      = 64
	MaxDataValueLen    = 1024
	MaxDataTotalBytes  = 8 * 1024
	MaxLocaleLen       = 16
	MaxDataValueNested = 4 // nested depth for data{} values
)

var (
	// company_id and source_id must be URL-safe and DB-friendly.
	idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,63}$`)
	// dedupe_key may include colons, dots, slashes (opaque to us).
	dedupeRe = regexp.MustCompile(`^[A-Za-z0-9._:/+\-]{1,128}$`)
	// severity one of the enum.
)

// Alert is the v1 wire shape. Tags are intentionally not in v1 — the
// source can put structured data in `Data` if it needs key/value lookup.
// FCM/Telegram routing keys off Severity + Category, not arbitrary tags.
type Alert struct {
	// ID is server-assigned on accept; empty in inbound payloads.
	ID string `json:"id,omitempty"`

	CompanyID string   `json:"company_id"`
	SourceID  string   `json:"source_id"`
	Severity  Severity `json:"severity"`
	Category  string   `json:"category,omitempty"`

	// Pre-localized (SPEC: source-localized, we pass through).
	Title string `json:"title"`
	Body  string `json:"body,omitempty"`

	// Data is an arbitrary key/value map for the recipient app
	// (e.g. deep links, structured context). Strictly size-bounded.
	Data map[string]string `json:"data,omitempty"`

	// DedupeKey is opaque. The dedupe window is per-source, 60s default.
	DedupeKey string `json:"dedupe_key,omitempty"`

	// ClientTsMs is the source's wall clock at send time. Server fills
	// ReceivedAt on accept.
	ClientTsMs int64 `json:"client_ts_ms,omitempty"`

	// Locale is BCP-47, optional. Used to pick a pre-localized copy
	// on the recipient side; the source can also pre-localize.
	Locale string `json:"locale,omitempty"`

	// Server-assigned. Not in inbound JSON.
	ReceivedAt  time.Time `json:"received_at,omitempty"`
	DedupeCount uint32    `json:"dedupe_count,omitempty"`
}

// ErrInvalid is returned by Validate. errs contains one or more
// human-readable reasons.
type ErrInvalid struct {
	Reasons []string
}

func (e *ErrInvalid) Error() string {
	return fmt.Sprintf("invalid alert: %v", e.Reasons)
}

// IsInvalid reports whether err is an *ErrInvalid. Useful in HTTP
// handlers to map to 400.
func IsInvalid(err error) bool {
	var e *ErrInvalid
	return errors.As(err, &e)
}

// Validate enforces the SPEC §22 layer 5 + the field caps above.
// It does NOT enforce the severity enum against the FCM mapping —
// that's a delivery-tier concern.
func (a *Alert) Validate() error {
	var reasons []string

	if !idRe.MatchString(a.CompanyID) {
		reasons = append(reasons, "company_id: must match [a-z0-9][a-z0-9_-]{1,63}")
	}
	if !idRe.MatchString(a.SourceID) {
		reasons = append(reasons, "source_id: must match [a-z0-9][a-z0-9_-]{1,63}")
	}
	if _, ok := ValidSeverities[a.Severity]; !ok {
		reasons = append(reasons, fmt.Sprintf("severity: must be one of info|warning|critical|inminent_colapse, got %q", a.Severity))
	}
	if len(a.Category) > MaxCategoryLen {
		reasons = append(reasons, fmt.Sprintf("category: max %d chars", MaxCategoryLen))
	}
	if a.Title == "" {
		reasons = append(reasons, "title: required")
	}
	if len(a.Title) > MaxTitleLen {
		reasons = append(reasons, fmt.Sprintf("title: max %d chars", MaxTitleLen))
	}
	if len(a.Body) > MaxBodyLen {
		reasons = append(reasons, fmt.Sprintf("body: max %d chars", MaxBodyLen))
	}
	if a.DedupeKey != "" && !dedupeRe.MatchString(a.DedupeKey) {
		reasons = append(reasons, "dedupe_key: must match [A-Za-z0-9._:/+-]{1,128}")
	}
	if len(a.Locale) > MaxLocaleLen {
		reasons = append(reasons, fmt.Sprintf("locale: max %d chars", MaxLocaleLen))
	}
	if len(a.Data) > MaxDataKeys {
		reasons = append(reasons, fmt.Sprintf("data: max %d keys", MaxDataKeys))
	}
	totalData := 0
	for k, v := range a.Data {
		if len(k) > MaxDataKeyLen {
			reasons = append(reasons, fmt.Sprintf("data key %q: max %d chars", k, MaxDataKeyLen))
		}
		if len(v) > MaxDataValueLen {
			reasons = append(reasons, fmt.Sprintf("data[%q]: max %d chars", k, MaxDataValueLen))
		}
		totalData += len(k) + len(v)
		if totalData > MaxDataTotalBytes {
			reasons = append(reasons, fmt.Sprintf("data: total bytes > %d", MaxDataTotalBytes))
			break
		}
	}

	if len(reasons) > 0 {
		return &ErrInvalid{reasons}
	}
	return nil
}

// NewID returns a new alert ID. ULID-ish: time-prefixed + random tail,
// 26 chars, lexically sortable. Good for the broker subject and for
// logs. Not crypto-strong.
func NewID() string {
	return ulidLike()
}
