package delivery

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

	"github.com/stretchr/testify/require"

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

func TestAlertWebURL(t *testing.T) {
	t.Parallel()

	require.Equal(t,
		"https://alerts.ops-dev.pingcap.net/ui/#/alerts/ALT-2607-42",
		alertWebURL("https://alerts.ops-dev.pingcap.net/ui/", "ALT-2607-42"),
	)
	require.Empty(t, alertWebURL("", "ALT-2607-42"))
	require.Empty(t, alertWebURL("javascript:alert(1)", "ALT-2607-42"))
}

func TestBuildAlertCardLinksInBody(t *testing.T) {
	t.Parallel()

	card := buildAlertCard(
		&alert.AlertAttrs{
			Alertname:      "TiDBDown",
			Classification: alert.ClassPage,
			Description:    "TiDB is unavailable",
			ClusterID:      "123",
			ClusterName:    "production",
			ClusterVersion: "v7.5.2",
			DeployType:     "Premium",
			TenantID:       "456",
			ClinicURL:      "https://clinic.pingcap.com/portal/#/clusters?query=123",
			Owned:          true,
		},
		&alert.Alert{ID: "ALT-2607-42", State: alert.StateOpen, ExternalIssueKey: "O11YCLOUD-999"},
		"https://alerts.ops-dev.pingcap.net/ui/",
		"https://jira.example.com/browse/O11YCLOUD-999",
		"https://ops.tidbcloud.com",
		"",
		nil,
	)

	require.Contains(t, card.Body, "TiDB is unavailable")
	require.Contains(t, card.Body, "**Cluster name**: production")
	require.Contains(t, card.Body, "**Product tier**: Premium")
	require.Contains(t, card.Body, "**Cluster version**: v7.5.2")
	require.NotContains(t, card.Body, "[Alert Details]")
	require.Contains(t, card.Body, "[Clinic](https://clinic.pingcap.com/portal/#/clusters?query=123)")
	require.Contains(t, card.Body, "[Ops Portal](https://ops.tidbcloud.com/orgs/456/clusters/123)")
	require.Contains(t, card.Body, "[Deprecated] [O11YCLOUD-999 in Jira]")
	require.Contains(t, card.Body, "https://jira.example.com/browse/O11YCLOUD-999")

	require.NotEmpty(t, card.ActionRows)
	var detailBtn *im.CardAction
	for _, row := range card.ActionRows {
		for i := range row {
			if row[i].Label == "Alert Details" {
				detailBtn = &row[i]
			}
		}
	}
	require.NotNil(t, detailBtn, "Alert Details button must be present")
	require.Equal(t, "https://alerts.ops-dev.pingcap.net/ui/#/alerts/ALT-2607-42", detailBtn.URL)
}

func TestBuildAlertBodyNamesTheTeamNeverThePerson(t *testing.T) {
	t.Parallel()

	attrs := &alert.AlertAttrs{Alertname: "TiDBDown", Classification: alert.ClassPage}
	a := &alert.Alert{ID: "ALT-2607-42", AssignedTo: "oncaller@pingcap.com"}

	body := buildAlertBody(attrs, a, "", "", "", "tikv", nil)
	require.Contains(t, body, "**Oncall team**: tikv")
	// The card is immutable, so the current owner must not appear in it.
	require.NotContains(t, body, "oncaller@pingcap.com")
	require.NotContains(t, body, "Assigned to")

	// An unroutable alert simply omits the line.
	require.NotContains(t, buildAlertBody(attrs, a, "", "", "", "", nil), "Oncall team")
}

func TestOwnerTextDistinguishesAssignmentFromReassignment(t *testing.T) {
	t.Parallel()

	// A page auto-assignment @-mentions the oncaller.
	page := ownerText(owner{Email: "oncaller@pingcap.com", UserID: "ou_abc123"}, "", "", true)
	require.Equal(t, "Assigned to <at id=ou_abc123></at>", page)

	// A ticket auto-assignment names its owner instead, without pinging.
	ticket := ownerText(
		owner{Email: "oncaller@pingcap.com", UserID: "ou_abc123", Name: "On Caller"}, "", "", false)
	require.Equal(t, "Assigned to On Caller", ticket)

	// A ticket owner with no resolved name falls back to email.
	require.Equal(t, "Assigned to oncaller@pingcap.com",
		ownerText(owner{Email: "oncaller@pingcap.com", UserID: "ou_abc123"}, "", "", false))

	// A human reassignment always @-mentions, page or not.
	reassigned := ownerText(
		owner{Email: "other@pingcap.com", UserID: "ou_def456", Name: "Other Person"},
		"wangkang@pingcap.com", "cluster under maintenance", false)
	require.Equal(t,
		"Reassigned to <at id=ou_def456></at> by wangkang@pingcap.com: cluster under maintenance",
		reassigned)

	// No directory entry: still announced, just without the ping.
	require.Equal(t, "Assigned to oncaller@pingcap.com",
		ownerText(owner{Email: "oncaller@pingcap.com"}, "", "", true))
}

func TestInstanceResolvedTextShowsRemainingState(t *testing.T) {
	t.Parallel()

	partial := instanceResolvedText(instanceResolvedPayload{
		InstanceID:      12,
		DisplayName:     "pod=tidb-0",
		RemainingFiring: 2,
	})
	require.Contains(t, partial, "Alertmanager instance recovered: pod=tidb-0")
	require.Contains(t, partial, "2 instance(s) are still firing")
	require.NotContains(t, partial, "resolve the alert")

	complete := instanceResolvedText(instanceResolvedPayload{
		InstanceID:  12,
		DisplayName: "pod=tidb-0",
	})
	require.Contains(t, complete, "All instances in this alert have recovered")
	require.Contains(t, complete, "resolve the alert")
}

func TestInstanceDisplayNameUsesIdentityLabel(t *testing.T) {
	t.Parallel()

	labels, err := json.Marshal(map[string]string{"namespace": "ignored", "pod": "tikv-1"})
	require.NoError(t, err)
	require.Equal(t, "pod=tikv-1", instanceDisplayName(&instance.Instance{
		Fingerprint: "abcdef",
		Labels:      labels,
	}))

	require.Equal(t, "", instanceDisplayName(&instance.Instance{
		ID:          42,
		Fingerprint: "abcdef",
	}))
}

func TestInstanceFiringTextCarriesTheOccurrenceOwnProse(t *testing.T) {
	t.Parallel()

	// The whole point of the reply: this occurrence's own text, which the
	// immutable card cannot show because it describes the first one.
	text := instanceFiringText(instanceFiringPayload{
		InstanceID:  13,
		DisplayName: "persistentvolumeclaim=data-tikv-2",
		Detail:      "disk /data2 at 91% is above the 90% threshold",
		TotalFiring: 3,
	})

	require.Contains(t, text,
		"Alertmanager instance firing: persistentvolumeclaim=data-tikv-2")
	require.Contains(t, text, "3 instance(s) are now firing")
	require.Contains(t, text, "disk /data2 at 91% is above the 90% threshold")
	require.NotContains(t, text, "View alert")

	// An occurrence with no identity label still announces itself.
	bare := instanceFiringText(instanceFiringPayload{InstanceID: 13, TotalFiring: 2})
	require.Contains(t, bare, "Alertmanager instance firing.")
	require.Contains(t, bare, "2 instance(s) are now firing")
}

func TestInstanceDetailPrefersDescriptionThenMessage(t *testing.T) {
	t.Parallel()

	detail := func(annotations map[string]string) string {
		raw, err := json.Marshal(annotations)
		require.NoError(t, err)
		return instanceDetail(&instance.Instance{Annotations: raw})
	}

	require.Equal(t, "desc", detail(map[string]string{
		"description": "desc", "message": "msg", "summary": "sum",
	}))
	require.Equal(t, "msg", detail(map[string]string{"message": "msg", "summary": "sum"}))
	require.Equal(t, "sum", detail(map[string]string{"summary": "sum"}))
	require.Equal(t, "", detail(map[string]string{"runbook_url": "https://example.com"}))
	require.Equal(t, "", instanceDetail(&instance.Instance{}))
}

func instancesFor(t *testing.T, states ...string) []instance.Instance {
	t.Helper()
	out := make([]instance.Instance, 0, len(states))
	for i, state := range states {
		labels, err := json.Marshal(map[string]string{"persistentvolumeclaim": fmt.Sprintf("data-tikv-%d", i)})
		require.NoError(t, err)
		out = append(out, instance.Instance{
			ID: int64(i), State: state, Labels: labels,
			Fingerprint: fmt.Sprintf("fingerprint-%d", i),
		})
	}
	return out
}

func TestAlertBodyListsOccurrencesOnceThereIsMoreThanOne(t *testing.T) {
	t.Parallel()

	attrs := &alert.AlertAttrs{
		Alertname: "TiKVDiskAlmostFull", Classification: alert.ClassPage,
		Summary:     "TiKV disk is almost full",
		Description: "disk /data0 at 87% is above the 85% threshold",
	}
	a := &alert.Alert{ID: "ALT-2607-42"}

	// A single occurrence keeps its own prose: nothing is being aggregated.
	single := buildAlertBody(attrs, a, "", "", "", "", instancesFor(t, instance.StateFiring))
	require.Contains(t, single, "disk /data0 at 87% is above the 85% threshold")
	require.NotContains(t, single, "**Instances**")

	// A second occurrence replaces the prose, which described only the first.
	multi := buildAlertBody(attrs, a, "", "", "", "",
		instancesFor(t, instance.StateResolved, instance.StateFiring, instance.StateFiring))
	require.Contains(t, multi, "**Instances**: 2 firing / 3 total")
	require.NotContains(t, multi, "disk /data0 at 87% is above the 85% threshold")
	require.NotContains(t, multi, "TiKV disk is almost full")

	// Detail per occurrence is deliberately absent; the web UI carries it.
	require.Contains(t, multi, "· persistentvolumeclaim=data-tikv-1 — firing")
	require.Contains(t, multi, "· persistentvolumeclaim=data-tikv-0 — recovered")

	// Firing sorts above recovered, whatever order the store returned.
	require.Less(t,
		strings.Index(multi, "data-tikv-1 — firing"),
		strings.Index(multi, "data-tikv-0 — recovered"))
}

// The header fields read as properties of the alert, the way alertmanager's
// commonLabels do for a group. attrs is frozen from the occurrence that opened
// the alert, and AggLabels does not cover component, region or tenant, so a
// value the occurrences disagree on has to say so rather than pass for common.
func TestAlertBodyHeaderDistinguishesCommonFromPerOccurrence(t *testing.T) {
	t.Parallel()

	labelled := func(labels map[string]string) instance.Instance {
		raw, err := json.Marshal(labels)
		require.NoError(t, err)
		return instance.Instance{State: instance.StateFiring, Labels: raw}
	}
	attrs := &alert.AlertAttrs{
		Alertname: "TiKVDiskAlmostFull", Classification: alert.ClassPage,
		ClusterID: "42", ClusterName: "production",
		Component: "tikv", Region: "us-east-1",
	}
	a := &alert.Alert{ID: "ALT-2607-42"}

	body := buildAlertBody(attrs, a, "", "", "", "", []instance.Instance{
		labelled(map[string]string{"tidb_cluster_id": "tidb42", "component": "tikv", "region": "us-east-1"}),
		labelled(map[string]string{"tidb_cluster_id": "tidb42", "component": "tiflash", "region": "us-east-1"}),
	})

	require.Contains(t, body, "**Component**: tiflash, tikv")
	require.Contains(t, body, "**Region**: us-east-1")
	require.Contains(t, body, "**Cluster**: 42")
	// Clinic enrichment never reaches an occurrence's labels and stays as it was
	// resolved at creation.
	require.Contains(t, body, "**Cluster name**: production")
}

func TestAlertBodyDegradesRatherThanOverflowingTheCard(t *testing.T) {
	t.Parallel()

	states := make([]string, maxListedInstances+1)
	for i := range states {
		states[i] = instance.StateFiring
	}
	body := buildAlertBody(&alert.AlertAttrs{Alertname: "TiKVDiskAlmostFull"},
		&alert.Alert{ID: "ALT-2607-42"}, "", "", "", "", instancesFor(t, states...))

	require.Contains(t, body, fmt.Sprintf("**Instances**: %d firing / %d total",
		maxListedInstances+1, maxListedInstances+1))
	require.Contains(t, body, "Too many to list here")
	require.NotContains(t, body, "· persistentvolumeclaim=data-tikv-0")
}

func TestPermissionDeniedIsNotRetried(t *testing.T) {
	t.Parallel()

	// A missing IM scope reaches the worker as a plain wrapped error, not a
	// permanentError, so the retry decision has to recognise it on its own.
	err := fmt.Errorf("buzz alert owner x@pingcap.com: %w",
		fmt.Errorf("make urgent: %w: code=99991672", im.ErrPermissionDenied))

	var perm *permanentError
	require.NotErrorAs(t, err, &perm)
	require.ErrorIs(t, err, im.ErrPermissionDenied,
		"the worker keys its no-retry decision on this")
}

func TestAlertCardOffersAckOnlyWhileUnacked(t *testing.T) {
	t.Parallel()

	attrs := &alert.AlertAttrs{Alertname: "TiDBDown", Classification: alert.ClassPage, Owned: true}

	open := buildAlertCard(attrs, &alert.Alert{ID: "ALT-2607-42", State: alert.StateOpen}, "", "", "", "tikv", nil)
	require.NotEmpty(t, open.ActionRows)
	require.Equal(t, "Ack", open.ActionRows[0][0].Label)
	require.Equal(t, map[string]string{
		im.ActionKey:     im.ActionAck,
		im.ActionAlertID: "ALT-2607-42",
	}, open.ActionRows[0][0].Value, "the click has to name the alert it acts on")

	// Once someone has it, the button has nothing left to offer — and leaving
	// it would let a later click take the alert off them.
	for _, state := range []string{alert.StateAcked, alert.StateResolved} {
		card := buildAlertCard(attrs, &alert.Alert{ID: "ALT-2607-42", State: state}, "", "", "", "tikv", nil)
		require.Empty(t, card.ActionRows, state)
	}
}

// Indistinguishable from an owned topic otherwise, so a reader would assume they
// had been paged and that acking here handles the alert.
func TestShadowAlertCardSaysItIsRecordedOnly(t *testing.T) {
	t.Parallel()

	shadow := &alert.AlertAttrs{Alertname: "TiDBDown", Classification: alert.ClassPage}
	a := &alert.Alert{ID: "ALT-2607-42", State: alert.StateOpen}

	card := buildAlertCard(shadow, a, "", "", "", "tikv", nil)
	require.Contains(t, card.Body, "**Recorded only**")
	require.Contains(t, card.Body, "jiralert is still the primary path")
	require.Empty(t, card.ActionRows, "acking would claim an alert this deployment is not handling")

	owned := *shadow
	owned.Owned = true
	require.NotContains(t, buildAlertCard(&owned, a, "", "", "", "tikv", nil).Body, "Recorded only")
}

func TestBuildAlertBodyPrintsOneProseBlock(t *testing.T) {
	t.Parallel()

	a := &alert.Alert{ID: "ALT-2607-42"}
	base := alert.AlertAttrs{Alertname: "TiDBDown", Classification: alert.ClassPage}

	// description wins over message, and message is not appended as well.
	attrs := base
	attrs.Summary = "TiDB is down"
	attrs.Description = "No TiDB pod has been ready for 5 minutes"
	attrs.Message = "No TiDB pod has been ready for 5 minutes"
	body := buildAlertBody(&attrs, a, "", "", "", "", nil)
	require.Contains(t, body, "TiDB is down")
	require.Equal(t, 1, strings.Count(body, "No TiDB pod has been ready for 5 minutes"))

	// Rules that only set message still get their prose.
	attrs = base
	attrs.Message = "No TiDB pod has been ready for 5 minutes"
	require.Contains(t, buildAlertBody(&attrs, a, "", "", "", "", nil),
		"No TiDB pod has been ready for 5 minutes")

	// A summary repeated as the description is printed once.
	attrs = base
	attrs.Summary = "TiDB is down"
	attrs.Description = "TiDB is down"
	require.Equal(t, 1, strings.Count(buildAlertBody(&attrs, a, "", "", "", "", nil), "TiDB is down"))
}
