package diplomail

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/google/uuid"
	"go.uber.org/zap"
)

// SendAdminPersonal persists an admin-kind message addressed to a
// single recipient and fan-outs the push event. The HTTP layer is
// responsible for the owner-vs-admin authorisation decision; this
// function trusts the caller designation it receives.
//
// The recipient may be in any membership status, so the lookup goes
// through MembershipLookup.GetMembershipAnyStatus. This lets the
// owner / admin reach a kicked player to explain the kick or follow
// up after a removal.
func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInput) (Message, Recipient, error) {
	subject, body, err := s.prepareContent(in.Subject, in.Body)
	if err != nil {
		return Message{}, Recipient{}, err
	}
	if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
		return Message{}, Recipient{}, err
	}

	recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, in.RecipientUserID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
		}
		return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
	}

	msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername,
		recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
	if err != nil {
		return Message{}, Recipient{}, err
	}
	rcptInsert := buildRecipientInsert(msgInsert.MessageID, recipient, msgInsert.BodyLang, s.nowUTC())

	msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
	if err != nil {
		return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: %w", err)
	}
	if len(recipients) != 1 {
		return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: unexpected recipient count %d", len(recipients))
	}

	if recipients[0].AvailableAt != nil { s.publishMessageReceived(ctx, msg, recipients[0]) }
	return msg, recipients[0], nil
}

// SendAdminBroadcast persists an admin-kind broadcast addressed to
// every member matching `RecipientScope`, then emits one push event
// per recipient. The caller's own membership row, when present, is
// excluded from the recipient list — broadcasters do not get a copy
// of their own message.
func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastInput) (Message, []Recipient, error) {
	subject, body, err := s.prepareContent(in.Subject, in.Body)
	if err != nil {
		return Message{}, nil, err
	}
	if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
		return Message{}, nil, err
	}
	scope, err := normaliseScope(in.RecipientScope)
	if err != nil {
		return Message{}, nil, err
	}

	members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, scope)
	if err != nil {
		return Message{}, nil, fmt.Errorf("diplomail: list members for broadcast: %w", err)
	}
	members = filterOutCaller(members, in.CallerUserID)
	if len(members) == 0 {
		return Message{}, nil, fmt.Errorf("%w: no recipients for broadcast", ErrInvalidInput)
	}

	gameName := members[0].GameName
	msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername,
		in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
	if err != nil {
		return Message{}, nil, err
	}
	rcptInserts := make([]RecipientInsert, 0, len(members))
	for _, m := range members {
		rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
	}

	msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
	if err != nil {
		return Message{}, nil, fmt.Errorf("diplomail: send admin broadcast: %w", err)
	}
	for _, r := range recipients {
		if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
	}
	return msg, recipients, nil
}

// SendPlayerBroadcast persists a paid-tier player broadcast and
// fans out the push event to every other active member of the game.
// The send is `kind="personal"`, `sender_kind="player"`,
// `broadcast_scope="game_broadcast"` — recipients reply to it as if
// it were a single-recipient personal send, and the reply targets
// only the broadcaster. The caller's entitlement tier is checked
// against `EntitlementReader`; free-tier callers are rejected with
// ErrForbidden.
func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcastInput) (Message, []Recipient, error) {
	subject, body, err := s.prepareContent(in.Subject, in.Body)
	if err != nil {
		return Message{}, nil, err
	}
	if s.deps.Entitlements == nil {
		return Message{}, nil, fmt.Errorf("%w: entitlement reader is not wired", ErrForbidden)
	}
	paid, err := s.deps.Entitlements.IsPaidTier(ctx, in.SenderUserID)
	if err != nil {
		return Message{}, nil, fmt.Errorf("diplomail: entitlement lookup: %w", err)
	}
	if !paid {
		return Message{}, nil, fmt.Errorf("%w: in-game broadcast requires a paid tier", ErrForbidden)
	}

	sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return Message{}, nil, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
		}
		return Message{}, nil, fmt.Errorf("diplomail: load sender membership: %w", err)
	}

	members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, RecipientScopeActive)
	if err != nil {
		return Message{}, nil, fmt.Errorf("diplomail: list active members: %w", err)
	}
	callerID := in.SenderUserID
	members = filterOutCaller(members, &callerID)
	if len(members) == 0 {
		return Message{}, nil, fmt.Errorf("%w: no other active members in this game", ErrInvalidInput)
	}

	username := sender.UserName
	msgInsert := MessageInsert{
		MessageID:      uuid.New(),
		GameID:         in.GameID,
		GameName:       sender.GameName,
		Kind:           KindPersonal,
		SenderKind:     SenderKindPlayer,
		SenderUserID:   &callerID,
		SenderUsername: &username,
		SenderIP:       in.SenderIP,
		Subject:        subject,
		Body:           body,
		BodyLang:       s.deps.Detector.Detect(body),
		BroadcastScope: BroadcastScopeGameBroadcast,
	}
	rcptInserts := make([]RecipientInsert, 0, len(members))
	for _, m := range members {
		rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
	}
	msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
	if err != nil {
		return Message{}, nil, fmt.Errorf("diplomail: send player broadcast: %w", err)
	}
	for _, r := range recipients {
		if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
	}
	return msg, recipients, nil
}

// SendAdminMultiGameBroadcast emits one admin-kind message per game
// resolved from the input scope and fans out the push events. A
// recipient who plays in multiple addressed games receives one
// independently-deletable inbox entry per game; this avoids cross-
// game leakage of admin context and keeps the per-game unread badge
// honest.
func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiGameBroadcastInput) ([]Message, int, error) {
	subject, body, err := s.prepareContent(in.Subject, in.Body)
	if err != nil {
		return nil, 0, err
	}
	if err := validateCaller(CallerKindAdmin, nil, in.CallerUsername); err != nil {
		return nil, 0, err
	}
	scope, err := normaliseScope(in.RecipientScope)
	if err != nil {
		return nil, 0, err
	}
	if s.deps.Games == nil {
		return nil, 0, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
	}
	games, err := s.resolveMultiGameTargets(ctx, in)
	if err != nil {
		return nil, 0, err
	}
	if len(games) == 0 {
		return nil, 0, fmt.Errorf("%w: no games match the broadcast scope", ErrInvalidInput)
	}

	totalRecipients := 0
	out := make([]Message, 0, len(games))
	for _, game := range games {
		members, err := s.deps.Memberships.ListMembers(ctx, game.GameID, scope)
		if err != nil {
			return nil, 0, fmt.Errorf("diplomail: list members for %s: %w", game.GameID, err)
		}
		if len(members) == 0 {
			s.deps.Logger.Debug("multi-game broadcast skips empty game",
				zap.String("game_id", game.GameID.String()),
				zap.String("scope", scope))
			continue
		}
		msgInsert, err := s.buildAdminMessageInsert(CallerKindAdmin, nil, in.CallerUsername,
			game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
		if err != nil {
			return nil, 0, err
		}
		rcptInserts := make([]RecipientInsert, 0, len(members))
		for _, m := range members {
			rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
		}
		msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
		if err != nil {
			return nil, 0, fmt.Errorf("diplomail: insert multi-game broadcast for %s: %w", game.GameID, err)
		}
		for _, r := range recipients {
			if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
		}
		out = append(out, msg)
		totalRecipients += len(recipients)
	}
	return out, totalRecipients, nil
}

func (s *Service) resolveMultiGameTargets(ctx context.Context, in SendMultiGameBroadcastInput) ([]GameSnapshot, error) {
	switch in.Scope {
	case MultiGameScopeAllRunning:
		games, err := s.deps.Games.ListRunningGames(ctx)
		if err != nil {
			return nil, fmt.Errorf("diplomail: list running games: %w", err)
		}
		return games, nil
	case MultiGameScopeSelected, "":
		if len(in.GameIDs) == 0 {
			return nil, fmt.Errorf("%w: selected scope requires game_ids", ErrInvalidInput)
		}
		out := make([]GameSnapshot, 0, len(in.GameIDs))
		for _, id := range in.GameIDs {
			game, err := s.deps.Games.GetGame(ctx, id)
			if err != nil {
				if errors.Is(err, ErrNotFound) {
					return nil, fmt.Errorf("%w: game %s not found", ErrInvalidInput, id)
				}
				return nil, fmt.Errorf("diplomail: load game %s: %w", id, err)
			}
			out = append(out, game)
		}
		return out, nil
	default:
		return nil, fmt.Errorf("%w: unknown multi-game scope %q", ErrInvalidInput, in.Scope)
	}
}

// BulkCleanup deletes every diplomail_messages row tied to games that
// finished more than `OlderThanYears` years ago. Returns the affected
// game ids and the count of removed messages. The minimum allowed
// value is 1 year — finer-grained pruning would risk wiping live
// arbitration evidence.
func (s *Service) BulkCleanup(ctx context.Context, in BulkCleanupInput) (CleanupResult, error) {
	if in.OlderThanYears < 1 {
		return CleanupResult{}, fmt.Errorf("%w: older_than_years must be >= 1", ErrInvalidInput)
	}
	if s.deps.Games == nil {
		return CleanupResult{}, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
	}
	cutoff := s.nowUTC().AddDate(-in.OlderThanYears, 0, 0)
	games, err := s.deps.Games.ListFinishedGamesBefore(ctx, cutoff)
	if err != nil {
		return CleanupResult{}, fmt.Errorf("diplomail: list finished games: %w", err)
	}
	if len(games) == 0 {
		return CleanupResult{}, nil
	}
	gameIDs := make([]uuid.UUID, 0, len(games))
	for _, g := range games {
		gameIDs = append(gameIDs, g.GameID)
	}
	deleted, err := s.deps.Store.DeleteMessagesForGames(ctx, gameIDs)
	if err != nil {
		return CleanupResult{}, fmt.Errorf("diplomail: bulk delete: %w", err)
	}
	return CleanupResult{GameIDs: gameIDs, MessagesDeleted: deleted}, nil
}

// ListMessagesForAdmin returns a paginated, optionally-filtered view
// of every persisted message. Used by the admin observability
// endpoint to inspect what has been sent and trace abuse reports.
func (s *Service) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) (AdminMessagePage, error) {
	rows, total, err := s.deps.Store.ListMessagesForAdmin(ctx, filter)
	if err != nil {
		return AdminMessagePage{}, err
	}
	page := filter.Page
	if page < 1 {
		page = 1
	}
	pageSize := filter.PageSize
	if pageSize < 1 {
		pageSize = 50
	}
	return AdminMessagePage{
		Items:    rows,
		Total:    total,
		Page:     page,
		PageSize: pageSize,
	}, nil
}

// PublishLifecycle persists a system-kind message in response to a
// lobby lifecycle transition and fan-outs push events to the
// affected recipients. Game-scoped transitions (`game.paused`,
// `game.cancelled`) reach every active member; membership-scoped
// transitions (`membership.removed`, `membership.blocked`) reach the
// kicked player only. Failures inside the function are logged at
// Warn level — lifecycle hooks must not block the lobby state
// machine on a downstream mail failure.
func (s *Service) PublishLifecycle(ctx context.Context, ev LifecycleEvent) error {
	switch ev.Kind {
	case LifecycleKindGamePaused, LifecycleKindGameCancelled:
		return s.publishGameLifecycle(ctx, ev)
	case LifecycleKindMembershipRemoved, LifecycleKindMembershipBlocked:
		return s.publishMembershipLifecycle(ctx, ev)
	default:
		return fmt.Errorf("%w: unknown lifecycle kind %q", ErrInvalidInput, ev.Kind)
	}
}

func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) error {
	members, err := s.deps.Memberships.ListMembers(ctx, ev.GameID, RecipientScopeActive)
	if err != nil {
		return fmt.Errorf("diplomail lifecycle: list members for %s: %w", ev.GameID, err)
	}
	if len(members) == 0 {
		s.deps.Logger.Debug("lifecycle skip: no active members",
			zap.String("game_id", ev.GameID.String()),
			zap.String("kind", ev.Kind))
		return nil
	}
	gameName := members[0].GameName
	subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)

	msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "",
		ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
	if err != nil {
		return err
	}
	rcptInserts := make([]RecipientInsert, 0, len(members))
	for _, m := range members {
		rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
	}
	msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
	if err != nil {
		return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
	}
	for _, r := range recipients {
		if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
	}
	return nil
}

func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEvent) error {
	if ev.TargetUser == nil {
		return fmt.Errorf("%w: membership lifecycle requires TargetUser", ErrInvalidInput)
	}
	target, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, ev.GameID, *ev.TargetUser)
	if err != nil {
		return fmt.Errorf("diplomail lifecycle: load target membership: %w", err)
	}
	subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)

	msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "",
		ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
	if err != nil {
		return err
	}
	rcptInsert := buildRecipientInsert(msgInsert.MessageID, target, msgInsert.BodyLang, s.nowUTC())
	msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
	if err != nil {
		return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
	}
	if len(recipients) == 1 && recipients[0].AvailableAt != nil {
		s.publishMessageReceived(ctx, msg, recipients[0])
	}
	return nil
}

// prepareContent normalises subject and body the same way SendPersonal
// does. Factored out so admin and lifecycle paths share the
// length-and-utf8 validation rules.
func (s *Service) prepareContent(subject, body string) (string, string, error) {
	subj := strings.TrimRight(subject, " \t")
	bod := strings.TrimRight(body, " \t\n")
	if err := s.validateContent(subj, bod); err != nil {
		return "", "", err
	}
	return subj, bod, nil
}

// buildAdminMessageInsert encapsulates the message-row construction
// for every admin-kind send. The CHECK constraint maps sender
// shapes:
//
//	sender_kind='player'  → CallerKind owner; sender_user_id set
//	sender_kind='admin'   → CallerKind admin; sender_user_id nil
//	sender_kind='system'  → CallerKind system; sender_username nil
func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.UUID, callerUsername string,
	gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
	out := MessageInsert{
		MessageID:      uuid.New(),
		GameID:         gameID,
		GameName:       gameName,
		Kind:           KindAdmin,
		SenderIP:       senderIP,
		Subject:        subject,
		Body:           body,
		BodyLang:       s.deps.Detector.Detect(body),
		BroadcastScope: scope,
	}
	switch callerKind {
	case CallerKindOwner:
		if callerUserID == nil {
			return MessageInsert{}, fmt.Errorf("%w: owner send requires caller user id", ErrInvalidInput)
		}
		uid := *callerUserID
		uname := callerUsername
		out.SenderKind = SenderKindPlayer
		out.SenderUserID = &uid
		out.SenderUsername = &uname
	case CallerKindAdmin:
		uname := callerUsername
		out.SenderKind = SenderKindAdmin
		out.SenderUsername = &uname
	case CallerKindSystem:
		out.SenderKind = SenderKindSystem
	default:
		return MessageInsert{}, fmt.Errorf("%w: unknown caller kind %q", ErrInvalidInput, callerKind)
	}
	return out, nil
}

// buildRecipientInsert turns a MemberSnapshot into a RecipientInsert.
// The race-name snapshot is nullable so a kicked player with no race
// name on file is still addressable.
//
// `bodyLang` is the detected language of the message body. When the
// recipient's preferred_language matches body_lang (or body_lang is
// undetermined), the function fills AvailableAt with `now` so the
// recipient row is materialised already-delivered; otherwise
// AvailableAt stays nil and the translation worker takes over.
func buildRecipientInsert(messageID uuid.UUID, m MemberSnapshot, bodyLang string, now time.Time) RecipientInsert {
	in := RecipientInsert{
		RecipientID:                uuid.New(),
		MessageID:                  messageID,
		GameID:                     m.GameID,
		UserID:                     m.UserID,
		RecipientUserName:          m.UserName,
		RecipientPreferredLanguage: normaliseLang(m.PreferredLanguage),
	}
	if m.RaceName != "" {
		race := m.RaceName
		in.RecipientRaceName = &race
	}
	if needsTranslation(bodyLang, in.RecipientPreferredLanguage) {
		// AvailableAt left nil → worker will deliver after the
		// translation cache is materialised (or after fallback).
	} else {
		t := now.UTC()
		in.AvailableAt = &t
	}
	return in
}

// needsTranslation reports whether a recipient with preferredLang
// needs to wait for a translated rendering before the message is
// considered delivered. Undetermined body language and empty
// recipient preferences are short-circuited to "no translation
// needed" so we never block delivery on something the detector
// could not label.
func needsTranslation(bodyLang, preferredLang string) bool {
	bodyLang = normaliseLang(bodyLang)
	preferredLang = normaliseLang(preferredLang)
	if bodyLang == "" || bodyLang == LangUndetermined {
		return false
	}
	if preferredLang == "" || preferredLang == LangUndetermined {
		return false
	}
	return bodyLang != preferredLang
}

// normaliseLang strips any region subtag and lowercases the result so
// `en-US` and `EN` both collapse to `en`. The diplomail layer uses
// ISO 639-1 codes; whatlanggo and LibreTranslate share that
// vocabulary.
func normaliseLang(tag string) string {
	tag = strings.TrimSpace(tag)
	if tag == "" {
		return ""
	}
	if i := strings.IndexAny(tag, "-_"); i > 0 {
		tag = tag[:i]
	}
	return strings.ToLower(tag)
}

func validateCaller(callerKind string, callerUserID *uuid.UUID, callerUsername string) error {
	switch callerKind {
	case CallerKindOwner:
		if callerUserID == nil {
			return fmt.Errorf("%w: owner send requires caller_user_id", ErrInvalidInput)
		}
		if callerUsername == "" {
			return fmt.Errorf("%w: owner send requires caller_username", ErrInvalidInput)
		}
	case CallerKindAdmin:
		if callerUsername == "" {
			return fmt.Errorf("%w: admin send requires caller_username", ErrInvalidInput)
		}
	case CallerKindSystem:
		// no extra checks
	default:
		return fmt.Errorf("%w: unknown caller_kind %q", ErrInvalidInput, callerKind)
	}
	return nil
}

func normaliseScope(scope string) (string, error) {
	switch scope {
	case "", RecipientScopeActive:
		return RecipientScopeActive, nil
	case RecipientScopeActiveAndRemoved, RecipientScopeAllMembers:
		return scope, nil
	default:
		return "", fmt.Errorf("%w: unknown recipient scope %q", ErrInvalidInput, scope)
	}
}

func filterOutCaller(members []MemberSnapshot, callerUserID *uuid.UUID) []MemberSnapshot {
	if callerUserID == nil {
		return members
	}
	out := make([]MemberSnapshot, 0, len(members))
	for _, m := range members {
		if m.UserID == *callerUserID {
			continue
		}
		out = append(out, m)
	}
	return out
}

// renderGameLifecycle returns the (subject, body) pair persisted for
// the `game.paused` / `game.cancelled` system message. Bodies are in
// English; Stage D will translate them on demand into each
// recipient's preferred_language and cache the result.
func renderGameLifecycle(kind, gameName, actor, reason string) (string, string) {
	actor = strings.TrimSpace(actor)
	if actor == "" {
		actor = "the system"
	}
	reasonTail := ""
	if r := strings.TrimSpace(reason); r != "" {
		reasonTail = " Reason: " + r + "."
	}
	switch kind {
	case LifecycleKindGamePaused:
		return "Game paused",
			fmt.Sprintf("The game %q has been paused by %s.%s", gameName, actor, reasonTail)
	case LifecycleKindGameCancelled:
		return "Game cancelled",
			fmt.Sprintf("The game %q has been cancelled by %s.%s", gameName, actor, reasonTail)
	}
	return "Game lifecycle update",
		fmt.Sprintf("The game %q has changed state.%s", gameName, reasonTail)
}

// renderMembershipLifecycle returns the (subject, body) pair persisted
// for the `membership.removed` / `membership.blocked` system message.
func renderMembershipLifecycle(kind, gameName, actor, reason string) (string, string) {
	actor = strings.TrimSpace(actor)
	if actor == "" {
		actor = "the system"
	}
	reasonTail := ""
	if r := strings.TrimSpace(reason); r != "" {
		reasonTail = " Reason: " + r + "."
	}
	switch kind {
	case LifecycleKindMembershipRemoved:
		return "Membership removed",
			fmt.Sprintf("Your membership in %q has been removed by %s.%s", gameName, actor, reasonTail)
	case LifecycleKindMembershipBlocked:
		return "Membership blocked",
			fmt.Sprintf("Your membership in %q has been blocked by %s.%s", gameName, actor, reasonTail)
	}
	return "Membership update",
		fmt.Sprintf("Your membership in %q has changed.%s", gameName, reasonTail)
}
