package delivery

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/tidbcloud/alertstore/pkg/agent"
	"github.com/tidbcloud/alertstore/pkg/alert"
	"github.com/tidbcloud/alertstore/pkg/event"
	"github.com/tidbcloud/alertstore/pkg/im"
	"github.com/tidbcloud/alertstore/pkg/instance"
	"github.com/tidbcloud/alertstore/pkg/jira"
	"github.com/tidbcloud/alertstore/pkg/oncall"
	"github.com/tidbcloud/alertstore/pkg/subscription"
)

// Dispatcher enqueues delivery tasks when alert lifecycle events happen.
// Actual sending runs asynchronously in the Worker with retries, so the
// webhook request path never blocks on (or loses work to) the IM API.
type Dispatcher struct {
	tasks     *TaskStore
	subs      *subscription.Store
	agentSubs *agent.SubscriptionStore
	jira      *jira.Client
	logger    *slog.Logger
}

func NewDispatcher(
	tasks *TaskStore,
	subs *subscription.Store,
	agentSubs *agent.SubscriptionStore,
	jiraClient *jira.Client,
	logger *slog.Logger,
) *Dispatcher {
	return &Dispatcher{tasks: tasks, subs: subs, agentSubs: agentSubs, jira: jiraClient, logger: logger}
}

// noAlertID is the alert a notice belongs to: none. A customer-facing
// notification is recorded and announced, never worked, so there is no alert to
// hang it on — and the outbox's uniqueness ("", kind, dedup key) still holds,
// because the dedup key names the event and the channel.
const noAlertID = ""

// OnAlertCreated enqueues the IM topic, plus the Jira issue for an owned alert,
// plus one dispatch per agent that asked for this alert. Enqueue is idempotent
// per alert, so callers invoke this on every firing event: a lost enqueue is
// repaired by the next webhook.
//
// All three are siblings, not a chain: an IM outage must not cost the issue
// automation reads, a Jira outage must not delay the page, and neither an agent
// that is down nor one that is slow may do either. The agent's answer arrives
// through its own callback whenever it arrives, and nothing here waits for it.
func (d *Dispatcher) OnAlertCreated(ctx context.Context, a *alert.Alert) error {
	if err := d.tasks.Enqueue(ctx, a.ID, KindIMTopic, "", nil); err != nil {
		return err
	}
	if err := d.enqueueAgentDispatch(ctx, a); err != nil {
		// The alert is recorded and the topic is queued. An agent that could not
		// be routed to is worth a log line, not a failed ingest — the whole
		// arrangement exists so that the response never depends on the agents.
		d.logger.Error("enqueue agent dispatch", "err", err, "alert_id", a.ID)
	}
	if !d.jiraOwns(a) {
		return nil
	}
	return d.tasks.Enqueue(ctx, a.ID, KindJiraCreate, "", nil)
}

// enqueueAgentDispatch fans the alert out to the agents whose filter matches.
//
// Shadow alerts are excluded for the same reason they get no owner and no
// forward: jiralert is still the primary path for them, so anything alertstore
// did here would be the second response to one condition.
func (d *Dispatcher) enqueueAgentDispatch(ctx context.Context, a *alert.Alert) error {
	if d.agentSubs == nil {
		return nil
	}
	var attrs alert.AlertAttrs
	if err := json.Unmarshal(a.Attrs, &attrs); err != nil {
		return fmt.Errorf("unmarshal alert attrs for agent dispatch: %w", err)
	}
	if !attrs.Owned {
		return nil
	}

	agents, err := d.agentSubs.FindMatching(ctx, subscription.TargetFromAlert(&attrs))
	if err != nil {
		return err
	}
	for _, sub := range agents {
		// The agent name is the dedup key, so one agent gets one dispatch per
		// alert however many firing events arrive.
		if err := d.tasks.Enqueue(ctx, a.ID, KindAgentDispatch, sub.Name, agentDispatchPayload{
			Agent:       sub.Name,
			TriggerKind: agent.TriggerAlertCreated,
		}); err != nil {
			return err
		}
	}
	return nil
}

// owned reports whether alertstore is the primary responder for this alert
// (decided at ingest, see alert.OwnedByAlertstore). A shadow alert gets no
// assignment, no subscription forward and no Jira issue — jiralert and jiraport
// are still doing all three. Gated here so it never enqueues such a task at all.
func (d *Dispatcher) owned(a *alert.Alert) bool {
	var attrs alert.AlertAttrs
	if err := json.Unmarshal(a.Attrs, &attrs); err != nil {
		d.logger.Error("unmarshal alert attrs for cutover gate", "err", err, "alert_id", a.ID)
		return false
	}
	return attrs.Owned
}

// jiraOwns adds the mirror's own precondition, so it can be switched off — and
// eventually deleted — independently of the cutover gate.
func (d *Dispatcher) jiraOwns(a *alert.Alert) bool {
	return d.jira.Enabled() && d.owned(a)
}

// enqueueJiraComment mirrors an occurrence change into the issue, keyed on the
// same event as the topic reply, reproducing jiralert's changelog comments.
func (d *Dispatcher) enqueueJiraComment(ctx context.Context, a *alert.Alert, dedupKey, text string) error {
	if !d.jiraOwns(a) {
		return nil
	}
	return d.tasks.Enqueue(ctx, a.ID, KindJiraComment, dedupKey, jiraCommentPayload{Text: text})
}

// OnInstanceResolved enqueues one occurrence-level transition reply. The event
// ID is the dedup key: webhook retries do not duplicate the reply, while two
// instances resolving in the same minute remain distinct notifications.
func (d *Dispatcher) OnInstanceResolved(
	ctx context.Context,
	a *alert.Alert,
	inst *instance.Instance,
	eventID int64,
	remainingFiring int,
) error {
	payload := instanceResolvedPayload{
		InstanceID:      inst.ID,
		DisplayName:     instanceDisplayName(inst),
		Fingerprint:     inst.Fingerprint,
		RemainingFiring: remainingFiring,
	}
	dedupKey := strconv.FormatInt(eventID, 10)
	if err := d.tasks.Enqueue(ctx, a.ID, KindIMResolvedReply, dedupKey, payload); err != nil {
		return err
	}
	return d.enqueueJiraComment(ctx, a, dedupKey, instanceResolvedText(payload))
}

// OnInstanceFiring enqueues the counterpart of OnInstanceResolved: an
// occurrence that joined an alert whose card was already posted. Without it the
// group only ever hears about such an occurrence when it recovers, and the card
// keeps describing whichever occurrence happened to open the alert.
//
// Not called for the occurrence that creates the alert — that is the card — nor
// for a repeat firing of one already bound.
func (d *Dispatcher) OnInstanceFiring(
	ctx context.Context,
	a *alert.Alert,
	inst *instance.Instance,
	eventID int64,
	totalFiring int,
) error {
	payload := instanceFiringPayload{
		InstanceID:  inst.ID,
		DisplayName: instanceDisplayName(inst),
		Fingerprint: inst.Fingerprint,
		Detail:      instanceDetail(inst),
		TotalFiring: totalFiring,
	}
	dedupKey := strconv.FormatInt(eventID, 10)
	if err := d.tasks.Enqueue(ctx, a.ID, KindIMFiringReply, dedupKey, payload); err != nil {
		return err
	}
	return d.enqueueJiraComment(ctx, a, dedupKey, instanceFiringText(payload))
}

// OnAlertInstancesChanged refreshes the posted card after the set of
// occurrences changed, in either direction. The card is rebuilt from current
// state, so the event ID only keeps a webhook retry from queuing the same
// refresh twice; two refreshes converge regardless of order.
//
// Silent by design: Lark does not re-notify on an edit. This keeps a card that
// someone opens later accurate, and is never the mechanism that tells the group
// something happened.
func (d *Dispatcher) OnAlertInstancesChanged(ctx context.Context, a *alert.Alert, eventID int64) error {
	return d.tasks.Enqueue(ctx, a.ID, KindIMCardRefresh, strconv.FormatInt(eventID, 10), nil)
}

// OnAlertStateChanged refreshes the posted card after a transition, which the
// card reflects through the presence of its Ack button, and moves the mirrored
// Jira issue to match. The refresh is keyed on the moment (a transition has no
// event ID; two refreshes converge), the Jira half on the target state, so an
// alert acked, reassigned and acked again moves the issue once.
func (d *Dispatcher) OnAlertStateChanged(ctx context.Context, a *alert.Alert, source string) error {
	if err := d.tasks.Enqueue(ctx, a.ID, KindIMCardRefresh,
		strconv.FormatInt(time.Now().UnixMilli(), 10), nil); err != nil {
		return err
	}
	if err := d.scheduleAckReminder(ctx, a); err != nil {
		return err
	}
	if !d.shouldSyncTransition(a, source) {
		return nil
	}
	return d.tasks.Enqueue(ctx, a.ID, KindJiraTransition, a.State,
		jiraTransitionPayload{State: a.State})
}

// scheduleAckReminder starts the ack-reminder ladder when an alert is acked and
// tears it down when it leaves the acked state. It is idempotent: the first
// reminder's dedup key is scoped to this ack (AckedAt), so repeated acked-state
// refreshes enqueue it once, while a reassign that unacks and a later re-ack
// each get their own chain.
func (d *Dispatcher) scheduleAckReminder(ctx context.Context, a *alert.Alert) error {
	switch a.State {
	case alert.StateAcked:
		if a.AckedAt == nil {
			return nil
		}
		gap := ackReminderDelay(a.Classification, 0)
		if gap <= 0 {
			return nil // not a worked classification
		}
		ackGen := a.AckedAt.UnixMilli()
		dedupKey := fmt.Sprintf("ackrem:%d:0", ackGen)
		return d.tasks.EnqueueAt(ctx, a.ID, KindAckReminder, dedupKey,
			ackReminderPayload{Step: 0, AckGen: ackGen}, gap)
	case alert.StateOpen, alert.StateResolved:
		// Unacked (reassigned) or resolved: whoever held it no longer owes a
		// close-out, so stop nagging them.
		_, err := d.tasks.CancelPending(ctx, a.ID, KindAckReminder)
		return err
	}
	return nil
}

// shouldSyncTransition is the loop guard, named so it is hard to delete by
// accident: closing an issue in Jira round-trips through alertstore's
// open -> acked -> resolved lifecycle, and echoing that back would move the
// issue to "In Progress" on the way past.
func (d *Dispatcher) shouldSyncTransition(a *alert.Alert, source string) bool {
	return source != alert.SourceIssueTracker && d.jiraOwns(a)
}

// OnComment mirrors a comment written via web/API into the alert's IM
// topic. The timeline row is already written by the caller (SSOT first);
// this is the interaction-surface echo, retried until the topic exists.
// dedupKey ties the task to the timeline entry.
func (d *Dispatcher) OnComment(ctx context.Context, a *alert.Alert, dedupKey, actor, text string) error {
	return d.tasks.Enqueue(ctx, a.ID, KindIMComment, dedupKey, commentPayload{Actor: actor, Text: text})
}

// OnAgentVerdict mirrors a finished run's finding into the alert's thread. The
// dedup key is the run ID, so a verdict reported twice replies once. The caller
// gates it exactly like the timeline note, so the reply appears when the note does.
func (d *Dispatcher) OnAgentVerdict(ctx context.Context, alertID string, runID int64, verdict, summary string) error {
	dedupKey := fmt.Sprintf("agent-run-%d", runID)
	return d.tasks.Enqueue(ctx, alertID, KindAgentVerdictReply, dedupKey,
		agentVerdictPayload{Verdict: verdict, Summary: summary})
}

// ResetEscalation cancels any pending escalation tasks for the alert and
// enqueues a fresh escalation starting at step 0 with a new generation. Called
// when a page-class alert is reassigned by a human.
//
// The rule's page_delay is deliberately not re-applied here. It buys a
// condition time to clear before anyone is woken, and by the time a person is
// handing the alert to someone else that question is settled — a person is
// already awake and has looked. Re-applying it would also make the first call
// something a chain of reassignments could postpone indefinitely.
func (d *Dispatcher) ResetEscalation(ctx context.Context, a *alert.Alert, newGeneration int) error {
	if _, err := d.tasks.CancelPending(ctx, a.ID, KindEscalation); err != nil {
		return err
	}

	var attrs alert.AlertAttrs
	if err := json.Unmarshal(a.Attrs, &attrs); err != nil {
		return fmt.Errorf("unmarshal alert attrs for escalation reset: %w", err)
	}
	if attrs.Classification != alert.ClassPage {
		return nil
	}

	var oncallTeam string
	if labels := attrs.Labels; labels != nil {
		oncallTeam = labels[oncall.TeamLabel]
	}
	if oncallTeam == "" {
		return nil
	}

	dedupKey := fmt.Sprintf("esc:%d:0", newGeneration)
	return d.tasks.Enqueue(ctx, a.ID, KindEscalation, dedupKey, escalationPayload{
		Step:       0,
		Generation: newGeneration,
		OncallTeam: oncallTeam,
	})
}

// OnReassigned announces an owner change to the alert's topic. Only for
// changes made outside the topic: a bot /assign already acknowledges itself
// in-thread, and announcing it twice is noise.
//
// Unlike a comment there is no timeline row to key on, so the dedup key is
// time-based. Two deliberate reassignments back to the same person stay
// distinct (the alternative, keying on the assignee alone, would silently
// swallow the second one); a double-submitted request within the same
// millisecond collapses.
func (d *Dispatcher) OnReassigned(ctx context.Context, a *alert.Alert, actor, assignee, reason string) error {
	dedupKey := fmt.Sprintf("%s:%d", assignee, time.Now().UnixMilli())
	return d.tasks.Enqueue(ctx, a.ID, KindIMOwner, dedupKey, ownerPayload{
		Assignee: assignee,
		Actor:    actor,
		Reason:   reason,
	})
}

// OnRecordedEvent announces one stored observation to the channels that asked
// for the raw stream.
//
// Two modes share this path because they share a unit — one card per
// Alertmanager notification — and differ in who already knows. A
// customer-facing observation has been sent to the tenant by cloud_server; an
// internal one has been sent to nobody. The visibility label picks which mode's
// subscriptions are asked, and nothing reaches both.
//
// Per event rather than per occurrence is the whole point. For the customer
// case it is what makes the internal view match the customer's: cloud_server
// pushes the moment Alertmanager delivers, with no aggregation of its own, so
// one notification to the customer is one card here. (That equivalence is only
// as good as the two feeds being the same notification — see the design note on
// routing both from one receiver.) For the internal case it is what an alert
// cannot show: an alert collapses an occurrence's repeat firings into silence,
// so "this has been re-firing hourly since Tuesday" has nowhere else to appear.
//
// The staleness window that governs alerts (alert.StalenessWindow) does not
// apply, and neither does anything else in the alert lifecycle. An observation
// that did land on an alert says so and links to it; it is still reported here
// in its own right.
//
// One task per channel, keyed on the event and that channel, so a group whose
// send fails is retried alone instead of re-delivering to everyone who already
// received it.
func (d *Dispatcher) OnRecordedEvent(ctx context.Context, ev *event.Event, attrs *event.EventAttrs, notice event.Notice) error {
	mode := subscription.ModeEvent
	if attrs.Visibility == alert.VisibilityExternal {
		mode = subscription.ModeCustomer
	}

	// The notice carries the observation's own classification, which is the one
	// the event was stored with; it is named here rather than taken from attrs
	// because this fan-out routes the observation, not the alert it landed on.
	target := subscription.TargetFromEvent(attrs)
	target.Classification = notice.Classification
	target.ObservationType = notice.ObservationType

	subs, err := d.subs.FindMatching(ctx, target, mode)
	if err != nil {
		return fmt.Errorf("find %s subscriptions: %w", mode, err)
	}
	if len(subs) == 0 {
		return nil
	}

	payload := buildNoticePayload(ev, attrs, mode, notice)
	for _, sub := range subs {
		payload.ChannelID = sub.ChannelID
		dedupKey := fmt.Sprintf("%d:%s", ev.ID, sub.ChannelID)
		if err := d.tasks.Enqueue(ctx, noAlertID, KindIMNotice, dedupKey, payload); err != nil {
			return err
		}
	}
	return nil
}

func buildNoticePayload(ev *event.Event, attrs *event.EventAttrs, mode string, notice event.Notice) noticePayload {
	p := noticePayload{
		Mode:            mode,
		EventID:         ev.ID,
		Status:          ev.Status,
		Alertname:       attrs.Alertname,
		Severity:        attrs.Labels["severity"],
		ClusterID:       attrs.ClusterID,
		ClusterName:     attrs.ClusterName,
		TenantID:        attrs.TenantID,
		Region:          attrs.Region,
		ClinicURL:       attrs.ClinicURL,
		NotifiedAt:      ev.ReceivedAt,
		Classification:  notice.Classification,
		ObservationType: notice.ObservationType,
		AlertID:         notice.AlertID,
		MuteRuleID:      notice.MuteRuleID,
		MuteReason:      notice.MuteReason,
	}
	if mode == subscription.ModeCustomer {
		p.Subject, p.Body = customerText(attrs)
		return p
	}
	// An internal observation follows the internal annotation convention, the
	// same one the alert card reads.
	p.Subject = strings.TrimSpace(attrs.Annotations["summary"])
	p.Body = strings.TrimSpace(attrs.Annotations["description"])
	if p.Body == "" {
		p.Body = strings.TrimSpace(attrs.Annotations["message"])
	}
	if p.Body == p.Subject {
		p.Body = ""
	}
	return p
}

// customerText extracts the prose the customer was sent.
//
// Customer-facing rules put the subject in `description` and the body in
// `message` — the reverse of the internal convention, where `summary` titles an
// alert and `description` elaborates on it. Reading them the internal way would
// print the title as the body and drop the body entirely. The pairing is
// cloud_server's (pkg/alert/notification/common.go: AlertAnnotationSubject,
// AlertAnnotationContent), and this is the copy of the same annotations.
func customerText(attrs *event.EventAttrs) (subject, body string) {
	subject = strings.TrimSpace(attrs.Annotations["description"])
	if subject == "" {
		subject = strings.TrimSpace(attrs.Annotations["summary"])
	}
	body = strings.TrimSpace(attrs.Annotations["message"])
	if body == subject {
		body = ""
	}
	return resolveCustomerPlaceholders(subject, attrs), resolveCustomerPlaceholders(body, attrs)
}

// resolveCustomerPlaceholders fills in the tokens cloud_server substitutes on
// its way to the customer, so the copy reads as what was sent rather than as
// the template behind it.
//
// This duplicates a contract owned by another repository, deliberately and
// narrowly: the alternative is a card showing a customer a literal
// "TiDBClusterName", which reads as a bug in the alert rather than as the
// notification it reports. Tokens we cannot resolve — the project name, which
// has no counterpart in the alert's labels — are left standing rather than
// guessed at.
func resolveCustomerPlaceholders(text string, attrs *event.EventAttrs) string {
	if text == "" {
		return ""
	}
	clusterName := attrs.ClusterName
	if clusterName == "" {
		clusterName = attrs.ClusterID
	}
	if clusterName != "" {
		text = strings.ReplaceAll(text, "TiDBClusterName", clusterName)
	}
	return strings.ReplaceAll(text, "TiDBCloudDomainName", tidbCloudDomain(attrs.Labels))
}

// tidbCloudDomain mirrors cloud_server's genTiDBCloudDomain: the console host
// a customer's notification links to, chosen by the environment the alert came
// from.
func tidbCloudDomain(labels map[string]string) string {
	const domain = "tidbcloud.com"
	switch bizType := alert.Fact(alert.FactBizType, labels); {
	case strings.HasPrefix(bizType, "dev"):
		return "dev." + domain
	case strings.HasPrefix(bizType, "staging"):
		return "staging." + domain
	default:
		return domain
	}
}

// buildNoticeCard renders one observation that opened no alert.
//
// It carries no action and names no owner, because there is nothing here to
// pick up. What it owes the reader is the opposite of an alert card — not "do
// something" but "know that this happened" — so the closing line says outright
// that nobody was paged. Without it the card is indistinguishable from one that
// did page somebody, and a reader would reasonably assume it is being handled.
func buildNoticeCard(p *noticePayload, webUIBaseURL string) *im.Card {
	body := fmt.Sprintf("**Alertname**: %s\n", p.Alertname)
	if p.Severity != "" {
		body += fmt.Sprintf("**Severity**: %s\n", p.Severity)
	}
	if p.ClusterID != "" {
		cluster := p.ClusterID
		if p.ClusterName != "" {
			cluster = fmt.Sprintf("%s (%s)", p.ClusterName, p.ClusterID)
		}
		body += fmt.Sprintf("**Cluster**: %s\n", cluster)
	}
	if p.TenantID != "" {
		body += fmt.Sprintf("**Tenant**: %s\n", p.TenantID)
	}
	if p.Region != "" {
		body += fmt.Sprintf("**Region**: %s\n", p.Region)
	}
	if observation := observationLabel(p.ObservationType); observation != "" {
		body += fmt.Sprintf("**Observation**: %s\n", observation)
	}
	body += fmt.Sprintf("**%s**: %s\n", noticeTimeLabel(p.Mode),
		p.NotifiedAt.UTC().Format("2006-01-02 15:04:05 UTC"))

	if p.Subject != "" {
		body += fmt.Sprintf("\n%s\n", p.Subject)
	}
	if p.Body != "" {
		body += fmt.Sprintf("\n%s\n", p.Body)
	}

	if webURL := eventWebURL(webUIBaseURL, p.EventID); webURL != "" {
		body += fmt.Sprintf("\n[Open in Alertstore](%s)", webURL)
	}
	// An observation that landed on an alert points at it: that alert is where
	// the response is happening, and following the observation there is the
	// whole reason to be watching this stream.
	if p.AlertID != "" {
		if alertURL := alertWebURL(webUIBaseURL, p.AlertID); alertURL != "" {
			body += fmt.Sprintf("\n[%s in Alertstore](%s)", p.AlertID, alertURL)
		}
	}
	if clinicURL := safeHTTPURL(p.ClinicURL); clinicURL != "" {
		body += fmt.Sprintf("\n[Open in Clinic](%s)", clinicURL)
	}
	body += "\n\n_" + noticeFooter(p) + "_"

	return &im.Card{
		Title: fmt.Sprintf("[%s %s] %s - %s", noticeLabel(p.Mode), p.Status, p.Alertname, p.ClusterID),
		Color: noticeColor(p.Mode, p.Status),
		Body:  body,
	}
}

// observationLabel names how this observation relates to the occurrence's own
// history, which is the question the alert view cannot answer.
func observationLabel(observationType string) string {
	switch observationType {
	case "first_firing":
		return "first firing"
	case "repeat_firing":
		return "repeat firing — the condition has not cleared since the last notification"
	case "resolved":
		return "resolved"
	default:
		return ""
	}
}

// noticeLabel titles the card with what it is, so the two streams stay
// distinguishable in a group that subscribed to both.
func noticeLabel(mode string) string {
	if mode == subscription.ModeCustomer {
		return "customer"
	}
	return "event"
}

func noticeTimeLabel(mode string) string {
	if mode == subscription.ModeCustomer {
		return "Notified at"
	}
	return "Observed at"
}

// noticeFooter states what was done about this observation, which is the one
// thing a reader cannot infer and must not guess. A card that stays silent
// about it reads like a page.
func noticeFooter(p *noticePayload) string {
	if p.Mode == subscription.ModeCustomer {
		return "The customer was notified directly. This is a copy for" +
			" visibility — no on-caller was assigned or paged."
	}
	if p.AlertID != "" {
		// Widening this stream past logging means a card can now describe an
		// observation somebody *is* working. Saying so is what stops a reader
		// double-handling it — and stops the reverse mistake, ignoring it
		// because event cards are usually nobody's problem.
		return "This observation belongs to " + p.AlertID +
			", which is where it is being handled. Nothing was paged from this card."
	}
	if p.MuteRuleID != "" {
		// A page-class observation that opened no alert is otherwise
		// inexplicable, and this stream is the only place an active mute rule
		// is visible while it is still suppressing something — mute spares the
		// record precisely so it can be read here.
		text := "Muted by an active rule (" + p.MuteRuleID + "): no alert was opened" +
			" and nobody was paged."
		if p.MuteReason != "" {
			text += " Reason given: " + p.MuteReason + "."
		}
		return text
	}
	return "Recorded only. This observation opened no alert —" +
		" nobody was assigned or paged."
}

// noticeColor keeps notices visually distinct from alerts: nothing here is red,
// because nothing here is a page. Customer-facing ones are warmer than internal
// ones for the same reason they are a separate mode — somebody outside is
// already looking at it.
func noticeColor(mode, status string) string {
	if status == "resolved" {
		return "green"
	}
	if mode == subscription.ModeCustomer {
		return "orange"
	}
	return "blue"
}

func eventWebURL(baseURL string, eventID int64) string {
	u, err := url.Parse(baseURL)
	if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
		return ""
	}
	u.Fragment = fmt.Sprintf("/events/%d", eventID)
	return u.String()
}

// --- shared card builders ---

func alertTitle(attrs *alert.AlertAttrs, a *alert.Alert) string {
	class := strings.ToUpper(attrs.Classification[:1]) + attrs.Classification[1:]
	if a.State == alert.StateResolved {
		prefix := "Resolved"
		if a.Resolution == alert.ResolutionExpired {
			prefix = "Expired"
		}
		return fmt.Sprintf("[%s %s] %s - %s", prefix, class, attrs.Alertname, attrs.ClusterID)
	}
	return fmt.Sprintf("[%s] %s - %s", class, attrs.Alertname, attrs.ClusterID)
}

// buildAlertBody renders the card. It carries no owner: who holds the alert
// moves as it is reassigned and lives in the topic's replies (KindIMOwner) and
// the web UI instead. Keeping the individual out also makes the card
// independent of the oncall service — the team comes from the alert's own
// label — so an outage there costs a reply, not a hole in the card.
//
// Once an alert holds more than one occurrence the prose is replaced by the
// occurrence list, because the prose describes only whichever occurrence opened
// the alert: two disks in one alert can trip at different thresholds. The list
// deliberately carries no per-occurrence detail — that is what the web UI is
// for, and a card that grows with the aggregate stops being readable.
//
// The header fields read as properties of the alert, so they come from
// alert.CommonFact rather than from attrs: those are frozen from the occurrence
// that opened the alert, and the aggregation key does not force the rest to
// agree with it.
func buildAlertBody(attrs *alert.AlertAttrs, a *alert.Alert, webUIBaseURL, issueURL, opsPortalBaseURL, team string, instances []instance.Instance) string {
	body := fmt.Sprintf("**Alert ID**: %s\n", a.ID)
	body += fmt.Sprintf("**Alertname**: %s\n", attrs.Alertname)
	body += fmt.Sprintf("**Classification**: %s\n", attrs.Classification)
	if cluster := alert.CommonFact(alert.FactClusterID, instances, attrs.ClusterID); cluster != "" {
		body += fmt.Sprintf("**Cluster**: %s\n", cluster)
	}
	// Clinic enrichment keys on the cluster ID and never reaches an occurrence's
	// labels, so these stay as they were resolved at creation.
	if attrs.ClusterName != "" {
		body += fmt.Sprintf("**Cluster name**: %s\n", attrs.ClusterName)
	}
	if attrs.DeployType != "" {
		body += fmt.Sprintf("**Product tier**: %s\n", attrs.DeployType)
	}
	if attrs.ClusterVersion != "" {
		body += fmt.Sprintf("**Cluster version**: %s\n", attrs.ClusterVersion)
	}
	if tenant := alert.CommonFact(alert.FactTenantID, instances, attrs.TenantID); tenant != "" {
		body += fmt.Sprintf("**Tenant**: %s\n", tenant)
	}
	// Ops-portal enrichment keys on the tenant ID and, like clinic's, never
	// reaches an occurrence's labels. Kind first: whether a customer is living
	// through this changes how the card is read.
	if attrs.TenantKind != "" {
		body += fmt.Sprintf("**Tenant kind**: %s\n", attrs.TenantKind)
	}
	if attrs.TenantName != "" {
		body += fmt.Sprintf("**Tenant name**: %s\n", attrs.TenantName)
	}
	if attrs.BizRegion != "" {
		body += fmt.Sprintf("**Biz region**: %s\n", attrs.BizRegion)
	}
	if attrs.CustomerType != "" {
		body += fmt.Sprintf("**Customer type**: %s\n", attrs.CustomerType)
	}
	if region := alert.CommonFact(alert.FactRegion, instances, attrs.Region); region != "" {
		body += fmt.Sprintf("**Region**: %s\n", region)
	}
	if component := alert.CommonFact(alert.FactComponent, instances, attrs.Component); component != "" {
		body += fmt.Sprintf("**Component**: %s\n", component)
	}
	if team != "" {
		body += fmt.Sprintf("**Oncall team**: %s\n", team)
	}
	// Acked-by moved to the action row (replacing the Ack button), so it is
	// visually in the same slot and the body stays a property sheet.
	if len(instances) > 1 {
		body += buildInstanceList(instances)
	} else {
		if attrs.Summary != "" {
			body += fmt.Sprintf("\n%s\n", attrs.Summary)
		}
		// One block of prose under the summary, matching the web UI. description is
		// the intended field; message stands in for the rules that predate that
		// convention. All three are lifted from the same annotation map, so a rule
		// that sets two of them would otherwise print the same text twice.
		detail := attrs.Description
		if detail == "" {
			detail = attrs.Message
		}
		if detail != "" && detail != attrs.Summary {
			body += fmt.Sprintf("\n%s", detail)
		}
	}
	// Alert Details is rendered as a URL button on the card action row.
	var extLinks []string
	if clinicURL := safeHTTPURL(attrs.ClinicURL); clinicURL != "" {
		extLinks = append(extLinks, fmt.Sprintf("[Clinic](%s)", clinicURL))
	}
	if opsURL := opsPortalURL(opsPortalBaseURL, attrs.TenantID, attrs.ClusterID); opsURL != "" {
		extLinks = append(extLinks, fmt.Sprintf("[Ops Portal](%s)", opsURL))
	}
	if len(extLinks) > 0 {
		body += "\n" + strings.Join(extLinks, "  ")
	}
	if issueURL := safeHTTPURL(issueURL); issueURL != "" {
		// The [Deprecated] tag sits outside the link text as plain markdown:
		// lark_md renders a backslash-escaped bracket (\[) literally, backslash
		// and all, so the tag can't live inside the link's own [...] label. A
		// bare `[Deprecated]` not followed by `(` is just literal text and shows
		// the brackets on its own.
		body += fmt.Sprintf("\n[Deprecated] [%s in Jira](%s)", a.ExternalIssueKey, issueURL)
	}
	// A shadow topic is indistinguishable from an owned one, so it has to say
	// what it is: nobody was paged from here and acking here handles nothing.
	if !attrs.Owned {
		body += "\n\n**Recorded only** — jiralert is still the primary path for this alert." +
			" Handle it there; nobody was assigned or notified from here."
	}
	// Transparency note: topic discussion is captured into the timeline.
	// "directly" is load-bearing. Capture matches on the topic's thread ID, so a
	// reply to a forwarded copy is not recorded — and the owner, who receives
	// exactly such a copy, is the person most likely to reply there.
	body += "\n\n_Replies directly in this topic are recorded in alertstore._"
	return body
}

// maxListedInstances bounds the card. How many occurrences an aggregate holds
// is decided upstream by the rule's group_by and the size of the fleet, not by
// us — alertmanager ships a truncated_alerts counter precisely because it has
// seen payloads overflow. Past this many, a card would be truncated by Lark or
// rejected outright, and both fail less legibly than one honest summary line.
const maxListedInstances = 50

func buildInstanceList(instances []instance.Instance) string {
	firing := 0
	for i := range instances {
		if instances[i].State == instance.StateFiring {
			firing++
		}
	}
	header := fmt.Sprintf("\n**Instances**: %d firing / %d total\n", firing, len(instances))
	if len(instances) > maxListedInstances {
		return header + "\nToo many to list here — open the alert in Alertstore.\n"
	}

	// Firing first: what still needs attention outranks what already 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
	})

	body := header
	for i := range sorted {
		state := sorted[i].State
		if state != instance.StateFiring {
			state = "recovered"
		}
		name := instanceDisplayName(&sorted[i])
		if name == "" {
			name = fmt.Sprintf("#%d", i+1)
		}
		body += fmt.Sprintf("· %s — %s\n", name, state)
	}
	return body
}

// ownerText renders the owner announcement for a topic reply. An empty actor
// means the on-call schedule chose them. An auto-assigned ticket names its owner
// without pinging; a page and any human reassignment @-mention.
func ownerText(o owner, actor, reason string, isPage bool) string {
	who := im.MentionTag(o.UserID, o.Email)
	if actor == "" {
		if !isPage {
			who = ownerName(o)
		}
		return "Assigned to " + who
	}
	text := fmt.Sprintf("Reassigned to %s by %s", who, actor)
	if reason != "" {
		text += ": " + reason
	}
	return text
}

// ownerName is an owner's plain-text label: display name, or email as fallback.
func ownerName(o owner) string {
	if o.Name != "" {
		return o.Name
	}
	return o.Email
}

func alertWebURL(baseURL, alertID string) string {
	u, err := url.Parse(baseURL)
	if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
		return ""
	}
	u.Fragment = "/alerts/" + alertID
	return u.String()
}

func opsPortalURL(baseURL, tenantID, clusterID string) string {
	if baseURL == "" || tenantID == "" || clusterID == "" {
		return ""
	}
	return strings.TrimRight(baseURL, "/") + "/orgs/" + tenantID + "/clusters/" + clusterID
}

func safeHTTPURL(raw string) string {
	u, err := url.Parse(raw)
	if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
		return ""
	}
	return u.String()
}

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

func instanceDisplayName(inst *instance.Instance) string {
	var labels map[string]string
	_ = json.Unmarshal(inst.Labels, &labels)
	for _, key := range instanceIdentityLabels {
		if value := labels[key]; value != "" {
			return fmt.Sprintf("%s=%s", key, value)
		}
	}
	return ""
}

// instanceDetail is the occurrence's own prose, which is the whole point of
// announcing it: two disks in one alert can trip at different thresholds, and
// the card only ever shows the first one's text.
func instanceDetail(inst *instance.Instance) string {
	var annotations map[string]string
	_ = json.Unmarshal(inst.Annotations, &annotations)
	for _, key := range []string{"description", "message", "summary"} {
		if value := strings.TrimSpace(annotations[key]); value != "" {
			return value
		}
	}
	return ""
}

func instanceFiringText(payload instanceFiringPayload) string {
	text := "Alertmanager instance firing"
	if payload.DisplayName != "" {
		text += ": " + payload.DisplayName
	}
	text += "."
	if payload.TotalFiring > 0 {
		text += fmt.Sprintf(" %d instance(s) are now firing.", payload.TotalFiring)
	}
	if payload.Detail != "" {
		text += "\n" + payload.Detail
	}
	return text
}

func instanceResolvedText(payload instanceResolvedPayload) string {
	text := "Alertmanager instance recovered"
	if payload.DisplayName != "" {
		text += ": " + payload.DisplayName
	}
	text += "."
	if payload.RemainingFiring > 0 {
		text += fmt.Sprintf(" %d instance(s) are still firing.", payload.RemainingFiring)
	} else {
		text += " All instances in this alert have recovered. Please review and resolve the alert with an appropriate resolution."
	}
	return text
}

// agentVerdictText renders a run's finding for the thread: a verdict label
// followed by the agent's summary.
func agentVerdictText(verdict, summary string) string {
	text := "Agent triage — " + agentVerdictLabel(verdict)
	if summary != "" {
		text += "\n" + summary
	}
	return text
}

func agentVerdictLabel(verdict string) string {
	switch verdict {
	case agent.VerdictNoise:
		return "likely noise"
	case agent.VerdictSelfRecovered:
		return "likely self-recovering"
	case agent.VerdictMitigated:
		return "mitigated"
	case agent.VerdictEscalation:
		return "needs a human"
	case agent.VerdictUnknown:
		return "inconclusive"
	default:
		return "reported"
	}
}

// buildAlertCard renders the alert's own card: the anchor of its topic, and
// the only surface here that can be rewritten. Anything that reflects current
// state belongs on it, and anything that records an event belongs in a reply.
//
// The Ack button follows from that. It is an affordance for the state "nobody
// has picked this up", so it appears exactly while that is true — and because
// the card is singular and refreshed, there is never a second, staler button
// somewhere else in the topic inviting a click that would take the alert back
// from whoever now holds it.
func buildAlertCard(attrs *alert.AlertAttrs, a *alert.Alert, webUIBaseURL, issueURL, opsPortalBaseURL, team string, instances []instance.Instance) *im.Card {
	// Classification sets the color while the alert is live; resolved overrides
	// to green (manually closed) or grey (auto-expired).
	color := classificationColor(attrs.Classification)
	if a.State == alert.StateResolved {
		if a.Resolution == alert.ResolutionExpired {
			color = "grey"
		} else {
			color = "green"
		}
	}
	card := &im.Card{
		Title: alertTitle(attrs, a),
		Color: color,
		Body:  buildAlertBody(attrs, a, webUIBaseURL, issueURL, opsPortalBaseURL, team, instances),
	}
	var actionRow []im.CardAction
	if a.State == alert.StateOpen && attrs.Owned {
		actionRow = append(actionRow, im.CardAction{
			Label: "Ack",
			Style: "primary",
			Value: map[string]string{
				im.ActionKey:     im.ActionAck,
				im.ActionAlertID: a.ID,
			},
		})
	} else if a.State == alert.StateAcked && a.AckedBy != "" {
		card.Note = "Acked by " + a.AckedBy
	}
	if webURL := alertWebURL(webUIBaseURL, a.ID); webURL != "" {
		actionRow = append(actionRow, im.CardAction{
			Label: "Alert Details",
			URL:   webURL,
		})
	}
	if len(actionRow) > 0 {
		card.ActionRows = [][]im.CardAction{actionRow}
	}
	return card
}

func classificationColor(class string) string {
	switch class {
	case alert.ClassPage:
		return "red"
	case alert.ClassTicket:
		return "yellow"
	default:
		return "blue"
	}
}
