package jira

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

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

// maxFieldLength matches jiralert's limit. Jira rejects an over-long field
// outright, which would fail the whole issue.
const maxFieldLength = 5000

// maxListedInstances bounds the occurrence table: how many an aggregate holds is
// decided upstream by the rule's group_by, and alertstore holds them all.
const maxListedInstances = 20

// Issue is the subset alertstore writes. Nothing reads it back, so it carries
// only what create needs.
type Issue struct {
	Project      string
	IssueType    string
	Summary      string
	Description  string
	Priority     string
	Labels       []string
	Components   []string
	CustomFields map[string]interface{}
}

// fields renders the create payload. Optional members are omitted rather than
// sent empty, since Jira rejects an unknown priority or component name.
func (i *Issue) fields() map[string]interface{} {
	f := map[string]interface{}{
		"project":     map[string]string{"key": i.Project},
		"issuetype":   map[string]string{"name": i.IssueType},
		"summary":     textutil.Truncate(i.Summary, 255),
		"description": textutil.Truncate(i.Description, maxFieldLength*4),
	}
	if i.Priority != "" {
		f["priority"] = map[string]string{"name": i.Priority}
	}
	if len(i.Labels) > 0 {
		f["labels"] = i.Labels
	}
	if len(i.Components) > 0 {
		components := make([]map[string]string, 0, len(i.Components))
		for _, name := range i.Components {
			components = append(components, map[string]string{"name": name})
		}
		f["components"] = components
	}
	for key, value := range i.CustomFields {
		f[key] = value
	}
	return map[string]interface{}{"fields": f}
}

// BuildIssue renders the issue jiralert would have created, close enough that
// automation matching on summary, component or custom fields keeps working.
//
// Deduplication deliberately differs: jiralert searched Jira for a reusable
// issue because Jira was its only memory, whereas alertstore has already decided
// what belongs together, so one alert is one issue.
func BuildIssue(cfg Config, a *alert.Alert, attrs *alert.AlertAttrs, instances []instance.Instance, alertURL string) *Issue {
	issue := &Issue{
		Project:      cfg.Project,
		IssueType:    issueType,
		Summary:      cfg.SummaryPrefix + attrs.Alertname,
		Description:  buildDescription(a, attrs, instances, alertURL),
		Priority:     priorityFor(attrs),
		Labels:       buildLabels(a, attrs),
		CustomFields: map[string]interface{}{},
	}
	if component := mapComponent(attrs.Component); component != "" {
		issue.Components = []string{component}
	}
	// Omitted rather than sent empty: Jira rejects some fields set to "".
	// Single-valued on purpose — downstream automation parses these, so they keep
	// jiralert's semantics (the occurrence that opened the alert) even when the
	// aggregate's occurrences disagree; the description carries the full picture.
	if attrs.TenantID != "" {
		issue.CustomFields[tenantField] = attrs.TenantID
	}
	if attrs.ClusterID != "" {
		issue.CustomFields[clusterField] = attrs.ClusterID
	}
	if attrs.TenantKind != "" {
		issue.CustomFields[tenantKindField] = attrs.TenantKind
	}
	issue.CustomFields[rawAlertField] = rawAlertData(attrs, instances)
	return issue
}

// buildLabels gives automation something stable to search on. The alert ID is
// the only label mapping an issue back to the alertstore record.
func buildLabels(a *alert.Alert, attrs *alert.AlertAttrs) []string {
	labels := []string{"alertstore", a.ID}
	if name := sanitizeLabel(attrs.Alertname); name != "" {
		labels = append(labels, name)
	}
	if class := sanitizeLabel(attrs.Classification); class != "" {
		labels = append(labels, class)
	}
	return labels
}

// sanitizeLabel makes a value usable as a Jira label, which may not contain
// whitespace.
func sanitizeLabel(value string) string {
	label := strings.Join(strings.Fields(value), "-")
	return strings.Trim(label, "-")
}

// priorityFor keeps jiralert's three levels. jiralert took them from the
// receiver; alertstore reads the severity label those receivers were routed by,
// falling back to classification.
func priorityFor(attrs *alert.AlertAttrs) string {
	switch strings.ToLower(attrs.Labels["severity"]) {
	case "critical":
		return "Critical"
	case "major":
		return "Major"
	case "warning":
		return "Warning"
	}
	switch attrs.Classification {
	case alert.ClassPage:
		return "Critical"
	case alert.ClassTicket:
		return "Major"
	default:
		return "Warning"
	}
}

func buildDescription(a *alert.Alert, attrs *alert.AlertAttrs, instances []instance.Instance, alertURL string) string {
	var b strings.Builder

	b.WriteString("|| *Alert Summary* ||\n")
	if alertURL != "" {
		fmt.Fprintf(&b, "| *Alertstore:* [%s|%s] |\n", a.ID, alertURL)
	} else {
		fmt.Fprintf(&b, "| *Alertstore:* %s |\n", a.ID)
	}
	fmt.Fprintf(&b, "| *Alertname:* %s |\n", attrs.Alertname)
	fmt.Fprintf(&b, "| *Classification:* %s |\n", attrs.Classification)
	if attrs.Summary != "" {
		fmt.Fprintf(&b, "| *Summary:* %s |\n", textutil.Truncate(attrs.Summary, maxFieldLength))
	}
	// description is the intended annotation, message the legacy one. Both come
	// from the same map, so printing both would repeat the text.
	if detail := firstDistinct(attrs.Description, attrs.Message, attrs.Summary); detail != "" {
		fmt.Fprintf(&b, "| *Message:* %s |\n", textutil.Truncate(detail, maxFieldLength))
	}

	// The label-derived rows describe the whole issue, so they are resolved
	// across every occurrence (alert.CommonFact) instead of being taken from
	// attrs, which is frozen from the occurrence that opened the alert. The
	// rows enriched from clinic and the ops portal have no occurrence-level
	// counterpart and stay as they were resolved then.
	b.WriteString("\n|| *Cluster Information* ||\n")
	wrote := false
	for _, row := range [][2]string{
		{"Clinic URL", clinicLink(attrs.ClinicURL)},
		{"Cluster ID", alert.CommonFact(alert.FactClusterID, instances, attrs.ClusterID)},
		{"Cluster Name", attrs.ClusterName},
		{"Cluster Version", attrs.ClusterVersion},
		{"Product Tier", attrs.DeployType},
		{"Tenant ID", alert.CommonFact(alert.FactTenantID, instances, attrs.TenantID)},
		{"Tenant Name", attrs.TenantName},
		{"Tenant Kind", attrs.TenantKind},
		{"Biz Region", attrs.BizRegion},
		{"Customer Type", attrs.CustomerType},
		{"Biz Type", alert.CommonFact(alert.FactBizType, instances, attrs.BizType)},
		{"Namespace", alert.CommonFact(alert.FactNamespace, instances, attrs.Namespace)},
		{"Region", alert.CommonFact(alert.FactRegion, instances, attrs.Region)},
	} {
		if row[1] == "" {
			continue
		}
		fmt.Fprintf(&b, "| *%s:* %s |\n", row[0], row[1])
		wrote = true
	}
	if !wrote {
		b.WriteString("| _No cluster information available_ |\n")
	}

	b.WriteString(buildInstanceSection(instances))

	b.WriteString("\n----\n")
	b.WriteString("_Created by alertstore. The alert record, its full occurrence history and its" +
		" discussion live in alertstore; this issue is a mirror kept for compatibility._\n")
	return b.String()
}

// buildInstanceSection gives a single occurrence its full label set, the way
// jiralert rendered a one-alert group, and an aggregate one row each.
func buildInstanceSection(instances []instance.Instance) string {
	if len(instances) == 0 {
		return ""
	}

	var b strings.Builder
	if len(instances) == 1 {
		inst := &instances[0]
		b.WriteString("\n|| *Alert Details* ||\n")
		fmt.Fprintf(&b, "| *Started At:* %s |\n", inst.StartsAt.Format("2006-01-02 15:04:05 MST"))
		if labels := codeBlock(decodeStringMap(inst.Labels)); labels != "" {
			fmt.Fprintf(&b, "| *Labels:* %s |\n", labels)
		}
		if annotations := codeBlock(decodeStringMap(inst.Annotations)); annotations != "" {
			fmt.Fprintf(&b, "| *Annotations:* %s |\n", annotations)
		}
		return b.String()
	}

	firing := 0
	for i := range instances {
		if instances[i].State == instance.StateFiring {
			firing++
		}
	}
	fmt.Fprintf(&b, "\n|| *Instances* || %d firing / %d total ||\n", firing, len(instances))

	// Firing first: what needs attention outranks what cleared.
	sorted := make([]instance.Instance, len(instances))
	copy(sorted, instances)
	sort.SliceStable(sorted, func(i, j int) bool {
		return sorted[i].State == instance.StateFiring && sorted[j].State != instance.StateFiring
	})
	for i := range sorted {
		if i >= maxListedInstances {
			fmt.Fprintf(&b, "| _and %d more — see alertstore_ | |\n", len(sorted)-maxListedInstances)
			break
		}
		state := sorted[i].State
		if state != instance.StateFiring {
			state = "recovered"
		}
		fmt.Fprintf(&b, "| %s | %s |\n", InstanceDisplayName(&sorted[i]), state)
	}
	return b.String()
}

// rawAlertData reproduces jiralert's raw-alert custom field, which downstream
// automation parses: the occurrence that opened the alert.
func rawAlertData(attrs *alert.AlertAttrs, instances []instance.Instance) string {
	payload := map[string]interface{}{
		"status":       "firing",
		"labels":       attrs.Labels,
		"annotations":  attrs.Annotations,
		"generatorURL": attrs.GeneratorURL,
		"fingerprint":  attrs.Fingerprint,
	}
	if len(instances) > 0 {
		first := &instances[0]
		payload["status"] = first.State
		payload["labels"] = decodeStringMap(first.Labels)
		payload["annotations"] = decodeStringMap(first.Annotations)
		payload["generatorURL"] = first.GeneratorURL
		payload["fingerprint"] = first.Fingerprint
		payload["startsAt"] = first.StartsAt
		if first.EndsAt != nil {
			payload["endsAt"] = first.EndsAt
		}
	}
	encoded, err := json.Marshal(payload)
	if err != nil {
		return ""
	}
	return textutil.Truncate(string(encoded), maxFieldLength)
}

var instanceIdentityLabels = []string{
	"pod", "instance", "persistentvolumeclaim", "store", "task_name", "name", "changefeed",
}

// InstanceDisplayName names an occurrence the way the IM card does, so the two
// surfaces do not disagree.
func InstanceDisplayName(inst *instance.Instance) string {
	labels := decodeStringMap(inst.Labels)
	for _, key := range instanceIdentityLabels {
		if value := labels[key]; value != "" {
			return fmt.Sprintf("%s=%s", key, value)
		}
	}
	if len(inst.Fingerprint) > 16 {
		return inst.Fingerprint[:16]
	}
	return inst.Fingerprint
}

func clinicLink(clinicURL string) string {
	if !strings.HasPrefix(clinicURL, "http://") && !strings.HasPrefix(clinicURL, "https://") {
		return ""
	}
	return fmt.Sprintf("[%s|%s]", clinicURL, clinicURL)
}

func codeBlock(values map[string]string) string {
	if len(values) == 0 {
		return ""
	}
	keys := make([]string, 0, len(values))
	for key := range values {
		keys = append(keys, key)
	}
	sort.Strings(keys)

	var b strings.Builder
	b.WriteString("{code}")
	for _, key := range keys {
		fmt.Fprintf(&b, "%s: %s\n", key, values[key])
	}
	b.WriteString("{code}")
	return textutil.Truncate(b.String(), maxFieldLength)
}

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

func firstDistinct(candidates ...string) string {
	last := candidates[len(candidates)-1]
	for _, candidate := range candidates[:len(candidates)-1] {
		if candidate != "" && candidate != last {
			return candidate
		}
	}
	return ""
}
