package delivery

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"time"

	"github.com/tidbcloud/alertstore/pkg/alert"
	"github.com/tidbcloud/alertstore/pkg/audit"
	"github.com/tidbcloud/alertstore/pkg/im"
	"github.com/tidbcloud/alertstore/pkg/instance"
	"github.com/tidbcloud/alertstore/pkg/jira"
	"github.com/tidbcloud/alertstore/pkg/metrics"
	"github.com/tidbcloud/alertstore/pkg/oncall"
	"github.com/tidbcloud/alertstore/pkg/subscription"
)

const (
	maxAttempts  = 8
	claimLease   = 2 * time.Minute
	pollInterval = 3 * time.Second
	batchSize    = 20
)

// permanentError marks a task failure that retrying cannot fix.
type permanentError struct{ err error }

func (e *permanentError) Error() string { return e.err.Error() }
func (e *permanentError) Unwrap() error { return e.err }

func permanent(err error) error { return &permanentError{err: err} }

// Worker polls the delivery_task outbox and executes tasks with retries.
type Worker struct {
	tasks            *TaskStore
	alertStore       *alert.Store
	subStore         *subscription.Store
	auditStore       *audit.Store
	instanceStore    *instance.Store
	im               im.Provider
	oncall           *oncall.Client
	jira             *jira.Client
	agents           *AgentSender
	mainChannelID    string
	webUIBaseURL     string
	opsPortalBaseURL string
	logger           *slog.Logger
}

func NewWorker(
	tasks *TaskStore,
	alertStore *alert.Store,
	subStore *subscription.Store,
	auditStore *audit.Store,
	instanceStore *instance.Store,
	imProvider im.Provider,
	oncallClient *oncall.Client,
	jiraClient *jira.Client,
	agents *AgentSender,
	mainChannelID string,
	webUIBaseURL string,
	opsPortalBaseURL string,
	logger *slog.Logger,
) *Worker {
	return &Worker{
		tasks:            tasks,
		alertStore:       alertStore,
		subStore:         subStore,
		auditStore:       auditStore,
		instanceStore:    instanceStore,
		im:               imProvider,
		oncall:           oncallClient,
		jira:             jiraClient,
		agents:           agents,
		mainChannelID:    mainChannelID,
		webUIBaseURL:     webUIBaseURL,
		opsPortalBaseURL: opsPortalBaseURL,
		logger:           logger,
	}
}

func (w *Worker) Run(ctx context.Context) {
	ticker := time.NewTicker(pollInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			w.poll(ctx)
		}
	}
}

func (w *Worker) poll(ctx context.Context) {
	if n, err := w.tasks.CountPending(ctx); err == nil {
		metrics.DeliveryPending.Set(float64(n))
	}

	due, err := w.tasks.ListDue(ctx, batchSize)
	if err != nil {
		w.logger.Error("list due delivery tasks", "err", err)
		return
	}

	for i := range due {
		task := &due[i]
		claimed, err := w.tasks.Claim(ctx, task.ID, claimLease)
		if err != nil {
			w.logger.Error("claim delivery task", "err", err, "task_id", task.ID)
			continue
		}
		if !claimed {
			continue
		}
		task.Attempts++ // Claim incremented it in the DB.
		w.execute(ctx, task)
	}
}

func (w *Worker) execute(ctx context.Context, t *Task) {
	err := w.run(ctx, t)
	if err == nil {
		if err := w.tasks.MarkDone(ctx, t.ID); err != nil {
			w.logger.Error("mark task done", "err", err, "task_id", t.ID)
		}
		metrics.DeliveryTasks.WithLabelValues(t.Kind, "success").Inc()
		return
	}

	// A missing IM scope is settled until an operator grants it, so it is
	// treated like an explicitly permanent failure rather than something eight
	// attempts across forty-five minutes might outlast.
	var perm *permanentError
	if errors.As(err, &perm) || errors.Is(err, im.ErrPermissionDenied) || t.Attempts >= maxAttempts {
		if merr := w.tasks.MarkFailed(ctx, t.ID, err.Error()); merr != nil {
			w.logger.Error("mark task failed", "err", merr, "task_id", t.ID)
		}
		metrics.DeliveryTasks.WithLabelValues(t.Kind, "failed").Inc()
		// The audit log is keyed by alert and read on the alert's own timeline.
		// A task that belongs to no alert (a notice) has nowhere to land there;
		// its record is the failed task row, the metric and this log line.
		if t.AlertID != "" {
			w.auditStore.Log(ctx, t.AlertID, "delivery_failed", "", "", "system", "system", map[string]interface{}{
				"kind":     t.Kind,
				"error":    err.Error(),
				"attempts": t.Attempts,
			})
		}
		w.logger.Error("delivery task failed permanently",
			"task_id", t.ID, "kind", t.Kind, "alert_id", t.AlertID, "attempts", t.Attempts, "err", err)
		return
	}

	backoff := backoffFor(t.Attempts)
	if merr := w.tasks.MarkRetry(ctx, t.ID, err.Error(), backoff); merr != nil {
		w.logger.Error("mark task retry", "err", merr, "task_id", t.ID)
	}
	metrics.DeliveryTasks.WithLabelValues(t.Kind, "retry").Inc()
	w.logger.Warn("delivery task failed, will retry",
		"task_id", t.ID, "kind", t.Kind, "alert_id", t.AlertID, "attempts", t.Attempts, "backoff", backoff, "err", err)
}

// backoffFor: 30s, 1m, 2m, 4m, 8m, 15m (capped).
func backoffFor(attempts int) time.Duration {
	d := 30 * time.Second
	for i := 1; i < attempts && d < 15*time.Minute; i++ {
		d *= 2
	}
	if d > 15*time.Minute {
		d = 15 * time.Minute
	}
	return d
}

func (w *Worker) run(ctx context.Context, t *Task) error {
	// A notice belongs to no alert, so it is dispatched before the load below —
	// which would otherwise fail it permanently on an alert ID that was never
	// meant to name one.
	if t.Kind == KindIMNotice {
		return w.runIMNotice(ctx, t)
	}

	a, err := w.alertStore.GetByID(ctx, t.AlertID)
	if err != nil {
		if errors.Is(err, alert.ErrNotFound) {
			return permanent(fmt.Errorf("alert %s not found", t.AlertID))
		}
		return err
	}

	switch t.Kind {
	case KindIMTopic:
		return w.runIMTopic(ctx, a)
	case KindIMForward:
		return w.runIMForward(ctx, t, a)
	case KindIMCard:
		return w.runIMCard(ctx, t, a)
	case KindIMResolvedReply:
		return w.runIMResolvedReply(ctx, t, a)
	case KindIMFiringReply:
		return w.runIMFiringReply(ctx, t, a)
	case KindIMCardRefresh:
		return w.runIMCardRefresh(ctx, a)
	case KindIMOwner:
		return w.runIMOwner(ctx, t, a)
	case KindIMOwnerNotify:
		return w.runIMOwnerNotify(ctx, t, a)
	case KindIMComment:
		return w.runIMComment(ctx, t, a)
	case KindEscalation:
		return w.runEscalation(ctx, t, a)
	case KindAckReminder:
		return w.runAckReminder(ctx, t, a)
	case KindAgentDispatch:
		return w.runAgentDispatch(ctx, t, a)
	case KindAgentVerdictReply:
		return w.runAgentVerdictReply(ctx, t, a)
	case KindJiraCreate:
		return w.runJiraCreate(ctx, a)
	case KindJiraComment:
		return w.runJiraComment(ctx, t, a)
	case KindJiraTransition:
		return w.runJiraTransition(ctx, t, a)
	default:
		return permanent(fmt.Errorf("unknown task kind %q", t.Kind))
	}
}

// runIMTopic posts the alert topic to the main group, then enqueues
// follow-up tasks (forwards, cards, urgent). The IM refs stored on the alert
// make retries idempotent: if the message was already sent, we skip sending
// and only fan out.
//
// It owns none of the owner handling: the card names only the team, so this
// task never touches the oncall service and cannot be delayed by it.
func (w *Worker) runIMTopic(ctx context.Context, a *alert.Alert) error {
	var attrs alert.AlertAttrs
	if err := json.Unmarshal(a.Attrs, &attrs); err != nil {
		return permanent(fmt.Errorf("unmarshal alert attrs: %w", err))
	}

	// The team comes from the alert's own label, so the card renders the same
	// whether or not the oncall service is reachable.
	team := w.oncall.TeamRef(attrs.Labels)

	threadID, messageID := a.IMThreadID, a.IMMessageID
	if threadID == "" {
		instances, err := w.instanceStore.ListByAlertID(ctx, a.ID)
		if err != nil {
			return fmt.Errorf("list instances for card: %w", err)
		}
		card := buildAlertCard(&attrs, a, w.webUIBaseURL, w.jira.IssueURL(a.ExternalIssueKey), w.opsPortalBaseURL, team, instances)
		mid, tid, err := w.im.SendCardToChannel(ctx, w.mainChannelID, card)
		if err != nil {
			return fmt.Errorf("send IM topic: %w", err)
		}
		messageID, threadID = mid, tid
		if err := w.alertStore.SetIMThread(ctx, a.ID, threadID, messageID, w.mainChannelID); err != nil {
			// The message is already out; failing the task here would
			// re-send it on retry. Log loudly and continue.
			w.logger.Error("store IM refs", "err", err, "alert_id", a.ID)
		}
		w.auditStore.Log(ctx, a.ID, "im_topic_created", "", "", "system", "system", map[string]interface{}{
			"thread_id":  threadID,
			"message_id": messageID,
			"channel_id": w.mainChannelID,
		})
	}

	// A shadow alert stops at the topic: forwarding it and handing it to an
	// on-caller would each be the second time, since jiraport already paged.
	if !attrs.Owned {
		return nil
	}

	// Fan out follow-up tasks. Each enqueue is idempotent, so a partial
	// failure retries this task without duplicating already-enqueued work.
	subs, err := w.subStore.FindMatching(ctx, subscription.TargetFromAlert(&attrs), subscription.AlertModes...)
	if err != nil {
		return fmt.Errorf("find matching subscriptions: %w", err)
	}

	for _, sub := range subs {
		switch sub.Mode {
		case subscription.ModeTopicForward:
			if threadID == "" {
				continue
			}
			if err := w.tasks.Enqueue(ctx, a.ID, KindIMForward, sub.ChannelID,
				forwardPayload{ThreadID: threadID, ChannelID: sub.ChannelID}); err != nil {
				return err
			}
		case subscription.ModeCardOnly:
			if err := w.tasks.Enqueue(ctx, a.ID, KindIMCard, sub.ChannelID,
				cardPayload{ChannelID: sub.ChannelID}); err != nil {
				return err
			}
		}
	}

	// Hand the alert to whoever is on call. An empty dedup key means one such
	// task per alert, however often this one retries.
	//
	// Not for an internal tenant: that is one of our own accounts — a test
	// cluster, an internal tier — and nobody outside is living through it, so
	// putting an on-caller on the hook at 3am is a cost with no one on the
	// other side of it. The alert is still recorded, still posted to the topic
	// and still mirrored to Jira; it simply arrives owned by nobody.
	//
	// Only the automatic hand-off is skipped. A human who picks it up — or
	// assigns it to someone — goes through OnReassigned, which enqueues this
	// same task and must keep working: "nobody is paged for it" is not "nobody
	// may hold it".
	if threadID != "" && !attrs.InternalTenant() {
		if err := w.tasks.Enqueue(ctx, a.ID, KindIMOwner, "", ownerPayload{}); err != nil {
			return err
		}
	}

	return nil
}

// owner is who an alert belongs to, in both identities the delivery layer
// needs: the email it is recorded under and the provider ID it is addressed by.
type owner struct {
	Email  string
	UserID string
	Name   string // display name, set only for a ticket that names its owner
}

// resolveOwner determines the alert's owner in both identities delivery needs.
// assignee, when set, is a reassignment the caller already committed; otherwise
// the on-call schedule decides and the result is persisted here.
//
// A nil error with an empty owner is a real answer — no team label, or a gap in
// the rotation — and must not be retried.
func (w *Worker) resolveOwner(ctx context.Context, a *alert.Alert, attrs *alert.AlertAttrs, assignee string) (owner, error) {
	email := assignee
	if email == "" {
		email = a.AssignedTo
	}

	if email == "" {
		assignment, err := w.oncall.ResolveAssignee(ctx, attrs.Labels)
		metrics.OncallLookups.WithLabelValues(assignment.Outcome).Inc()
		if err != nil {
			w.logger.Error("resolve on-call owner",
				"err", err, "team", assignment.Team, "alert_id", a.ID)
			if errors.Is(err, oncall.ErrTerminal) {
				// A missing team or a refused request needs a human to fix the
				// alert rule or the config. Leave the alert unowned and stop —
				// the metric and this log are the signal, not eight retries.
				return owner{}, nil
			}
			return owner{}, err
		}
		if assignment.Assignee == "" {
			w.logger.Warn("alert has no on-call owner",
				"outcome", assignment.Outcome, "team", assignment.Team, "alert_id", a.ID)
			return owner{}, nil
		}

		email = assignment.Assignee
		if err := w.alertStore.Assign(ctx, a.ID, email); err != nil {
			return owner{}, fmt.Errorf("record on-call owner: %w", err)
		}
		a.AssignedTo = email // keep the card and later steps in sync
		w.auditStore.Log(ctx, a.ID, "assigned", "", "", "system", "oncall", map[string]interface{}{
			"assignee":   email,
			"team":       assignment.Team,
			"lane":       assignment.Outcome,
			"candidates": assignment.Candidates,
		})
	}

	userID, err := w.im.GetUserIDByEmail(ctx, email)
	if err != nil {
		if errors.Is(err, im.ErrUserNotFound) {
			// Recorded as owner, but unreachable in IM: no @-mention, no buzz.
			// Retrying cannot fix a missing directory entry.
			w.logger.Warn("alert owner is not an IM user", "owner", email, "alert_id", a.ID)
			return owner{Email: email}, nil
		}
		return owner{Email: email}, fmt.Errorf("resolve IM user for alert owner %s: %w", email, err)
	}
	return owner{Email: email, UserID: userID}, nil
}

func (w *Worker) runIMForward(ctx context.Context, t *Task, a *alert.Alert) error {
	var p forwardPayload
	if err := json.Unmarshal(t.Payload.Raw(), &p); err != nil {
		return permanent(fmt.Errorf("unmarshal forward payload: %w", err))
	}

	if err := w.im.ForwardThread(ctx, p.ThreadID, p.ChannelID); err != nil {
		return fmt.Errorf("forward thread: %w", err)
	}
	w.auditStore.Log(ctx, a.ID, "im_forwarded", "", "", "system", "system", map[string]interface{}{
		"target_channel": p.ChannelID,
		"mode":           "topic_forward",
	})
	return nil
}

func (w *Worker) runIMCard(ctx context.Context, t *Task, a *alert.Alert) error {
	var p cardPayload
	if err := json.Unmarshal(t.Payload.Raw(), &p); err != nil {
		return permanent(fmt.Errorf("unmarshal card payload: %w", err))
	}

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

	instances, err := w.instanceStore.ListByAlertID(ctx, a.ID)
	if err != nil {
		return fmt.Errorf("list instances for card: %w", err)
	}
	card := buildAlertCard(&attrs, a, w.webUIBaseURL, w.jira.IssueURL(a.ExternalIssueKey),
		w.opsPortalBaseURL, w.oncall.TeamRef(attrs.Labels), instances)
	if _, err := w.im.SendCard(ctx, p.ChannelID, card); err != nil {
		return fmt.Errorf("send card: %w", err)
	}
	w.auditStore.Log(ctx, a.ID, "im_card_sent", "", "", "system", "system", map[string]interface{}{
		"target_channel": p.ChannelID,
		"mode":           "card_only",
	})
	return nil
}

// runIMNotice posts one customer-facing notification to one subscribed channel.
//
// Everything it renders was decided at enqueue, so it neither reads the alert
// store nor resolves an owner: there is no alert and nobody is on the hook.
// That also makes it the one task kind that cannot be delayed by any of
// alertstore's own dependencies being down.
func (w *Worker) runIMNotice(ctx context.Context, t *Task) error {
	var p noticePayload
	if err := json.Unmarshal(t.Payload.Raw(), &p); err != nil {
		return permanent(fmt.Errorf("unmarshal notice payload: %w", err))
	}
	if p.ChannelID == "" {
		return permanent(errors.New("notice has no target channel"))
	}

	if _, err := w.im.SendCard(ctx, p.ChannelID, buildNoticeCard(&p, w.webUIBaseURL)); err != nil {
		return fmt.Errorf("send customer notice: %w", err)
	}
	metrics.NoticesDelivered.WithLabelValues(p.Status).Inc()
	return nil
}

// runIMOwner records who owns the alert and tells the group. It is the durable
// half of a handover: the reply is the only place outside the database that
// says who holds this alert, and for a reassignment it is the only evidence the
// handover happened at all.
//
// Reaching that person is split into im_owner_notify, so a bot that cannot
// message them — or an IM scope nobody granted — costs the delivery, never the
// record. Enqueueing before replying keeps the retry honest: the enqueue is
// idempotent, so a failed reply retries only the reply.
func (w *Worker) runIMOwner(ctx context.Context, t *Task, a *alert.Alert) error {
	var p ownerPayload
	if err := json.Unmarshal(t.Payload.Raw(), &p); err != nil {
		return permanent(fmt.Errorf("unmarshal owner payload: %w", err))
	}
	if a.IMThreadID == "" || a.IMMessageID == "" {
		// The topic task has not run yet; retry until it has.
		return errors.New("alert has no IM topic yet")
	}
	var attrs alert.AlertAttrs
	if err := json.Unmarshal(a.Attrs, &attrs); err != nil {
		return permanent(fmt.Errorf("unmarshal alert attrs: %w", err))
	}

	owner, err := w.resolveOwner(ctx, a, &attrs, p.Assignee)
	if err != nil {
		return err
	}
	if owner.Email == "" {
		// No team, or a gap in the rotation. A real answer, not a failure.
		return nil
	}

	// The dedup key is inherited so each handover gets its own delivery:
	// handing an alert back to someone who held it before must reach them
	// again, not collapse into the notification they already got.
	if owner.UserID != "" {
		if err := w.tasks.Enqueue(ctx, a.ID, KindIMOwnerNotify, t.DedupKey,
			ownerNotifyPayload{Assignee: owner.Email, UserID: owner.UserID}); err != nil {
			return err
		}
	}

	// A ticket names its owner rather than pinging; best-effort, since ownerText
	// falls back to the email.
	isPage := attrs.Classification == alert.ClassPage
	if p.Actor == "" && !isPage && owner.UserID != "" {
		if name, err := w.im.GetUserName(ctx, owner.UserID); err != nil {
			w.logger.Warn("resolve owner display name", "err", err, "owner", owner.Email, "alert_id", a.ID)
		} else {
			owner.Name = name
		}
	}

	content, _ := json.Marshal(map[string]string{
		"text": ownerText(owner, p.Actor, p.Reason, isPage),
	})
	if err := w.im.ReplyToThread(ctx, a.IMMessageID, string(content)); err != nil {
		return fmt.Errorf("announce alert owner: %w", err)
	}

	// Start phone-call escalation for page-class alerts on initial assignment
	// (no actor means the oncall schedule chose them, not a human reassign).
	if p.Actor == "" && isPage {
		w.startEscalation(ctx, a, &attrs, 0)
	}
	return nil
}

// runIMOwnerNotify reaches the owner personally: the topic in their own chat,
// and a buzz on it when the alert is a page.
//
// The buzz is best-effort and deliberately gets no retry budget. It is a nudge
// to look, not the page itself — a phone call will be that — and retrying it
// through the outbox would mean re-forwarding, because the message it buzzes is
// created in this call. Retrying it in place would be worse still: the worker
// runs tasks one at a time, so sleeping here stalls every alert behind this one.
// A failure is recorded and the task completes.
func (w *Worker) runIMOwnerNotify(ctx context.Context, t *Task, a *alert.Alert) error {
	var p ownerNotifyPayload
	if err := json.Unmarshal(t.Payload.Raw(), &p); err != nil {
		return permanent(fmt.Errorf("unmarshal owner notify payload: %w", err))
	}
	if p.UserID == "" {
		return permanent(errors.New("owner notify has no IM user to reach"))
	}
	if a.IMThreadID == "" {
		return errors.New("alert has no IM topic yet")
	}
	var attrs alert.AlertAttrs
	if err := json.Unmarshal(a.Attrs, &attrs); err != nil {
		return permanent(fmt.Errorf("unmarshal alert attrs: %w", err))
	}

	forwardedID, err := w.im.ForwardThreadToUser(ctx, a.IMThreadID, p.UserID)
	if err != nil {
		return fmt.Errorf("forward topic to owner %s: %w", p.Assignee, err)
	}
	w.auditStore.Log(ctx, a.ID, "im_forwarded", "", "", "system", "system", map[string]interface{}{
		"target_user": p.Assignee,
		"mode":        "owner_direct",
	})

	if attrs.Classification != alert.ClassPage || forwardedID == "" {
		return nil
	}
	if err := w.im.MakeUrgent(ctx, forwardedID, []string{p.UserID}); err != nil {
		w.logger.Warn("buzz alert owner", "err", err, "owner", p.Assignee, "alert_id", a.ID)
		w.auditStore.Log(ctx, a.ID, "im_urgent_failed", "", "", "system", "system", map[string]interface{}{
			"oncaller": p.Assignee,
			"error":    err.Error(),
		})
		return nil
	}
	w.auditStore.Log(ctx, a.ID, "im_urgent", "", "", "system", "system", map[string]interface{}{
		"oncaller": p.Assignee,
		"method":   "im_urgent",
	})
	return nil
}

// runAgentDispatch hands the alert to one agent. The run row is created here,
// so an agent that is dispatched and never answers is visible as exactly that
// rather than as nothing at all.
//
// It touches no lifecycle state and enqueues no follow-up. Whatever the agent
// makes of the alert arrives later through the callback, on its own schedule,
// and the alert's own delivery has long since finished by then.
func (w *Worker) runAgentDispatch(ctx context.Context, t *Task, a *alert.Alert) error {
	var p agentDispatchPayload
	if err := json.Unmarshal(t.Payload.Raw(), &p); err != nil {
		return permanent(fmt.Errorf("unmarshal agent dispatch payload: %w", err))
	}
	if w.agents == nil || !w.agents.Enabled() {
		return permanent(errors.New("agent dispatch is not configured"))
	}

	run, err := w.agents.Send(ctx, a, p)
	if err != nil {
		return err
	}
	w.auditStore.Log(ctx, a.ID, "agent_dispatched", "", "", "system", "system", map[string]interface{}{
		"agent":        p.Agent,
		"run_id":       run.ID,
		"trigger_kind": run.TriggerKind,
		"disclosure":   run.Disclosure,
	})
	return nil
}

func (w *Worker) runIMComment(ctx context.Context, t *Task, a *alert.Alert) error {
	var p commentPayload
	if err := json.Unmarshal(t.Payload.Raw(), &p); err != nil {
		return permanent(fmt.Errorf("unmarshal comment payload: %w", err))
	}

	target := a.IMMessageID
	if target == "" {
		target = a.IMThreadID
	}
	if target == "" {
		return errors.New("alert has no IM topic yet")
	}

	content, _ := json.Marshal(map[string]string{
		"text": fmt.Sprintf("%s: %s", p.Actor, p.Text),
	})
	if err := w.im.ReplyToThread(ctx, target, string(content)); err != nil {
		return fmt.Errorf("mirror comment to thread: %w", err)
	}
	return nil
}

// runAgentVerdictReply replies a finished run's verdict into the thread,
// mirroring runIMComment; it retries until the topic exists.
func (w *Worker) runAgentVerdictReply(ctx context.Context, t *Task, a *alert.Alert) error {
	target := a.IMMessageID
	if target == "" {
		target = a.IMThreadID
	}
	if target == "" {
		// The topic task has not run yet; retry until it has.
		return errors.New("alert has no IM topic yet")
	}

	var p agentVerdictPayload
	if err := json.Unmarshal(t.Payload.Raw(), &p); err != nil {
		return permanent(fmt.Errorf("unmarshal agent verdict payload: %w", err))
	}
	content, _ := json.Marshal(map[string]string{
		"text": agentVerdictText(p.Verdict, p.Summary),
	})
	if err := w.im.ReplyToThread(ctx, target, string(content)); err != nil {
		return fmt.Errorf("reply agent verdict to thread: %w", err)
	}
	return nil
}

// runIMCardRefresh rewrites the posted card from the alert's current state.
// It rebuilds rather than patching incrementally, so two occurrences arriving
// at once converge on the same card whichever order the tasks run in.
//
// Editing is silent in Lark: this keeps the card honest for whoever opens it
// later, and never replaces the reply that actually notifies the group.
func (w *Worker) runIMCardRefresh(ctx context.Context, a *alert.Alert) error {
	if a.IMMessageID == "" {
		// The topic task has not run yet; retry until it has.
		return errors.New("alert has no IM topic yet")
	}
	var attrs alert.AlertAttrs
	if err := json.Unmarshal(a.Attrs, &attrs); err != nil {
		return permanent(fmt.Errorf("unmarshal alert attrs: %w", err))
	}
	instances, err := w.instanceStore.ListByAlertID(ctx, a.ID)
	if err != nil {
		return fmt.Errorf("list instances for card: %w", err)
	}
	// Rebuilt unconditionally: the card carries state now — the Ack button
	// comes and goes with it — so "only one occurrence" no longer means
	// "nothing has changed".
	team := w.oncall.TeamRef(attrs.Labels)
	card := buildAlertCard(&attrs, a, w.webUIBaseURL, w.jira.IssueURL(a.ExternalIssueKey), w.opsPortalBaseURL, team, instances)
	if err := w.im.UpdateCard(ctx, a.IMMessageID, card); err != nil {
		return fmt.Errorf("refresh card: %w", err)
	}
	return nil
}

func (w *Worker) runIMFiringReply(ctx context.Context, t *Task, a *alert.Alert) error {
	target := a.IMMessageID
	if target == "" {
		target = a.IMThreadID
	}
	if target == "" {
		// The topic task has not run yet; retry until it has.
		return errors.New("alert has no IM topic yet")
	}

	var payload instanceFiringPayload
	if err := json.Unmarshal(t.Payload.Raw(), &payload); err != nil {
		return permanent(fmt.Errorf("unmarshal instance firing payload: %w", err))
	}
	content, _ := json.Marshal(map[string]string{
		"text": instanceFiringText(payload),
	})
	if err := w.im.ReplyToThread(ctx, target, string(content)); err != nil {
		return fmt.Errorf("reply to thread: %w", err)
	}
	return nil
}

func (w *Worker) runIMResolvedReply(ctx context.Context, t *Task, a *alert.Alert) error {
	target := a.IMMessageID
	if target == "" {
		target = a.IMThreadID
	}
	if target == "" {
		return errors.New("alert has no IM topic yet")
	}

	var payload instanceResolvedPayload
	if err := json.Unmarshal(t.Payload.Raw(), &payload); err != nil {
		return permanent(fmt.Errorf("unmarshal instance resolved payload: %w", err))
	}
	content, _ := json.Marshal(map[string]string{
		"text": instanceResolvedText(payload),
	})
	if err := w.im.ReplyToThread(ctx, target, string(content)); err != nil {
		return fmt.Errorf("reply to thread: %w", err)
	}
	return nil
}
