// Package services — ReminderService — hourly bundled-digest reminder mail.
//
// Runs one goroutine for the process lifetime. Every hour it walks the user
// list, and for each user inside their morning or evening slot it builds a
// single bundled email summarising every pending deadline that matters to
// that user, grouped by category. One email per (user, slot, local-date)
// — no per-deadline mail, no Mondays-only digest.
//
// Three deadline categories drive the email layout (computed in the user's
// local timezone on each tick):
//
//   * overdue     — due_date < today (status=pending). System-failure
//                   framing — we engineer this away. Audience: owner +
//                   escalation channel. The escalation channel is the
//                   owner's user.escalation_contact_id when set, else the
//                   firm's global_admins (fallback). Settings → Notifications
//                   exposes the override (t-paliad-066).
//   * due_today   — due_date == today (status=pending). Day-of awareness in
//                   the morning slot; DRINGEND escalation framing in the
//                   evening slot. Audience: owner + project leads (+
//                   escalation channel on the evening slot — same
//                   contact-or-admins fallback as overdue).
//   * due_warning — due_date == today + user.reminder_warning_offset_days
//                   (default 7), status=pending. Heads-up section. Audience:
//                   owner + project leads. Morning slot only.
//
// Per-(user,slot,local-date) dedup uses paliad.reminder_log.slot/slot_date
// (migration 025). Legacy rows (slot IS NULL) coexist and are ignored.
//
// t-paliad-064 redesign — replaces the prior per-deadline kinds (overdue /
// tomorrow / due_today_evening / weekly) and per-mail templates with one
// bundled deadline_digest.html.
package services

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

	// Embed Go's IANA tz database. Mirrors the import in cmd/server/main.go
	// so the services test binary also has tz data available — without this,
	// `go test ./internal/services` would pass on a dev host (which has OS
	// tzdata) but the prod alpine binary would still be broken, and the tz
	// regression test in this package would be vacuous.
	_ "time/tzdata"

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"

	"mgit.msbls.de/m/paliad/internal/models"
)

// ReminderService wires the hourly reminder job. Construct with NewReminderService,
// start with Start(ctx), stop by cancelling the parent context.
type ReminderService struct {
	db    *sqlx.DB
	mail  *MailService
	users *UserService

	// baseURL is the frontend origin used in email links. Defaults to
	// https://paliad.de; override via PALIAD_BASE_URL for staging/preview.
	baseURL string

	// clock returns the current time. Exposed for tests so they can pin
	// "today" without having to freeze time globally.
	clock func() time.Time
}

// NewReminderService wires the service. The MailService may be disabled
// (Enabled() == false) — in that case Start still runs so logs show what
// would have gone out, but every Send is a no-op.
func NewReminderService(db *sqlx.DB, mail *MailService, users *UserService, baseURL string) *ReminderService {
	if baseURL == "" {
		baseURL = "https://paliad.de"
	}
	return &ReminderService{
		db:      db,
		mail:    mail,
		users:   users,
		baseURL: baseURL,
		clock:   func() time.Time { return time.Now() },
	}
}

// Start spawns the boundary-aligned scanner goroutine. Returns immediately;
// the loop exits when ctx is cancelled.
func (s *ReminderService) Start(ctx context.Context) {
	go s.loop(ctx)
}

// nextTopOfHour returns the duration from now until the next HH:00:00 in
// absolute time. Used to align the scanner's wake-up to natural hour
// boundaries instead of the container-start offset (t-paliad-069).
//
// Pre-fix the loop used `time.NewTicker(time.Hour)` directly: a deploy at
// 13:27:50 produced ticks at HH:27:50 forever, drifting the user-visible
// arrival of a 09:00-Berlin digest anywhere in the 09:xx hour and — worse —
// completely missing slots when redeploys clustered inside the slot hour.
// time.Truncate operates on absolute time, so the boundary is HH:00:00 UTC;
// for whole-hour-offset zones (e.g. Europe/Berlin = UTC±N) that's also
// HH:00:00 wall-clock locally, which is what users care about.
func nextTopOfHour(now time.Time) time.Duration {
	next := now.Truncate(time.Hour).Add(time.Hour)
	return next.Sub(now)
}

func (s *ReminderService) loop(ctx context.Context) {
	slog.Info("reminder: starting boundary-aligned scanner",
		"mail_enabled", s.mail.Enabled())

	// Startup catch-up: fire any user/slot whose configured hour has already
	// arrived today but no log row exists yet. Covers redeploys during or
	// after a slot hour — without this, a single mistimed deploy can lose a
	// day for affected users (the regular tick filter requires
	// local.Hour() == slot_hour, which is only true for one hour per day).
	// The slot_date dedup makes re-firing safe: if the previous container
	// already logged the slot, this is a no-op.
	s.runStartupCatchUp(ctx)

	// Aligned wait loop. nextTopOfHour is recomputed every iteration so any
	// clock skew or RunOnce duration self-corrects rather than accumulating.
	for {
		timer := time.NewTimer(nextTopOfHour(s.clock()))
		select {
		case <-ctx.Done():
			timer.Stop()
			slog.Info("reminder: shutdown")
			return
		case <-timer.C:
		}
		s.RunOnce(ctx)
	}
}

// RunOnce performs one scan+send pass for the regular hourly tick — fires
// only the slots whose configured hour matches the current local hour for
// each user. Exposed so tests (and, later, an admin trigger endpoint) can
// exercise the path without waiting for the ticker. Errors on individual
// users are logged and swallowed so one bad row doesn't block the scan.
func (s *ReminderService) RunOnce(ctx context.Context) {
	s.scanForSlots(ctx, "tick", func(now time.Time, u models.User, slot string) bool {
		return inSlot(now, u.ReminderTimezone, u.ReminderMorningTime, u.ReminderEveningTime, slot)
	})
}

// runStartupCatchUp fires any user/slot whose configured hour has already
// arrived today (regardless of the current hour) but has no log row yet —
// see loop() for the rationale. Goes through the same runSlotForUser path
// as RunOnce, so the slot_date dedup, audience filter, and email shape all
// match the regular tick.
func (s *ReminderService) runStartupCatchUp(ctx context.Context) {
	s.scanForSlots(ctx, "startup-catchup", func(now time.Time, u models.User, slot string) bool {
		return slotPastDueToday(now, u.ReminderTimezone, u.ReminderMorningTime, u.ReminderEveningTime, slot)
	})
}

// scanForSlots is the shared scan body: load all users, walk morning+evening,
// and call runSlotForUser for each that passes filterFn. label distinguishes
// the two callers in logs.
func (s *ReminderService) scanForSlots(
	ctx context.Context,
	label string,
	filterFn func(now time.Time, u models.User, slot string) bool,
) {
	now := s.clock()

	if s.users == nil {
		slog.Warn("reminder: UserService not wired, skipping scan", "label", label)
		return
	}
	users, err := s.users.List(ctx)
	if err != nil {
		slog.Warn("reminder: list users failed", "label", label, "error", err)
		return
	}

	for _, u := range users {
		for _, slot := range []string{"morning", "evening"} {
			if !filterFn(now, u, slot) {
				continue
			}
			if !reminderEnabled(u.EmailPreferences, "deadline_reminders") {
				continue
			}
			if err := s.runSlotForUser(ctx, now, u, slot); err != nil {
				slog.Warn("reminder: slot run failed",
					"label", label, "user_id", u.ID, "slot", slot, "error", err)
			}
		}
	}
}

// runSlotForUser emits at most one digest email for (user, slot, local-date).
// Returns nil on send-skipped (empty digest, dedup hit) and on success.
// Errors signal infrastructure problems (DB / mail) the caller can log.
func (s *ReminderService) runSlotForUser(ctx context.Context, now time.Time, u models.User, slot string) error {
	loc, err := time.LoadLocation(u.ReminderTimezone)
	if err != nil {
		// Defense in depth — inSlot already screens this, but if we get here
		// don't try to send with a bogus tz.
		return fmt.Errorf("load tz %q: %w", u.ReminderTimezone, err)
	}
	local := now.In(loc)
	today := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)

	already, err := s.hasDigestSent(ctx, u.ID, slot, today)
	if err != nil {
		return fmt.Errorf("dedup check: %w", err)
	}
	if already {
		return nil
	}

	rows, err := s.fetchSlotDeadlines(ctx, u, today, slot)
	if err != nil {
		return fmt.Errorf("fetch deadlines: %w", err)
	}
	if len(rows) == 0 {
		// Nothing for this user in this slot — no email, no log row. The
		// next slot will check again. Per the design we deliberately do
		// NOT send "everything is quiet" ack mail.
		return nil
	}

	if err := s.deliverDigest(u, slot, rows); err != nil {
		return fmt.Errorf("deliver: %w", err)
	}
	return s.logDigestSend(ctx, u.ID, slot, today)
}

// digestRow is one deadline as it will appear in a digest email. Categories
// are computed in Go from due_date so we don't have to encode the offset
// twice (SQL + template).
type digestRow struct {
	DeadlineID       uuid.UUID  `db:"deadline_id"`
	Title            string     `db:"title"`
	DueDate          time.Time  `db:"due_date"`
	OwnerID          uuid.UUID  `db:"owner_id"`
	OwnerName        string     `db:"owner_name"`
	ProjectReference string     `db:"project_reference"`
	ProjectTitle     string     `db:"project_title"`
	IsLead           bool       `db:"is_lead"`
	// ApprovalStatus (t-paliad-138). When 'pending', the digest renders
	// the row with a "[PENDING] " title prefix so the user can't miss
	// that the deadline is unverified — silence on a pending change is
	// the worst outcome.
	ApprovalStatus   string     `db:"approval_status"`
	// OwnerEscalationContactID is the owner's optional escalation override:
	// non-NULL diverts overdue/DRINGEND escalation away from global_admins
	// to the named user. Used by visibleForCategory to decide whether the
	// global-admin fallback applies for this row.
	OwnerEscalationContactID *uuid.UUID `db:"owner_escalation_contact_id"`

	// Filled in Go after the SELECT.
	Category string
}

// fetchSlotDeadlines pulls every pending deadline that matters to u in this
// slot, applies the per-category audience filter, and returns the rows the
// digest should render.
//
// SQL fans out across the three audience predicates (owner, project lead
// along path, global admin). The per-category recipient rules (e.g. leads
// don't get overdue) are applied in Go after the rows return — keeps the
// SQL portable and the rule-table readable.
func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User, today time.Time, slot string) ([]digestRow, error) {
	isGlobalAdmin := u.GlobalRole == "global_admin"
	offset := u.ReminderWarningOffsetDays
	if offset < 1 {
		offset = 7
	}

	// Build the date predicate per slot. Positional placeholders only —
	// sqlx.Named can't be used because the query body contains PostgreSQL
	// `::TYPE` cast operators and sqlx eats the second `:` as a named-arg
	// prefix.
	//   $1 = today
	//   $2 = userid
	//   $3 = is_global_admin
	// `offset` is interpolated as a literal int (clamped ≥1 above) — keeping
	// it as a parameter would force every slot's query to declare it even
	// when unused (evening), and Postgres can't infer the type of an
	// unreferenced parameter.
	//   morning: overdue OR due_today OR due_warning(today+offset)
	//   evening: overdue OR due_today  (no +offset heads-up in the evening)
	var dateCond string
	if slot == "evening" {
		dateCond = `(f.due_date < $1 OR f.due_date = $1)`
	} else {
		dateCond = fmt.Sprintf(`(f.due_date < $1
		             OR f.due_date = $1
		             OR f.due_date = ($1::date + '%d days'::interval)::date)`, offset)
	}

	// Audience predicates:
	//   * owner of the deadline                        — f.created_by = U
	//   * project lead anywhere on the path             — pt.responsibility = 'lead'
	//   * owner's escalation contact (override)        — own.escalation_contact_id = U
	//   * global admin AND owner has no override       — fallback channel
	// Per-category recipient rules (e.g. leads don't get overdue) are applied
	// in Go by visibleForCategory so the rule table stays readable.
	query := `
		SELECT f.id                              AS deadline_id,
		       f.title                           AS title,
		       f.due_date                        AS due_date,
		       f.approval_status                 AS approval_status,
		       f.created_by                      AS owner_id,
		       COALESCE(own.display_name, '')    AS owner_name,
		       own.escalation_contact_id         AS owner_escalation_contact_id,
		       COALESCE(p.reference, '')         AS project_reference,
		       p.title                           AS project_title,
		       EXISTS (
		           SELECT 1 FROM paliad.project_teams pt
		            WHERE pt.user_id = $2
		              AND pt.responsibility = 'lead'
		              AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
		       )                                 AS is_lead
		  FROM paliad.deadlines f
		  JOIN paliad.projects p   ON p.id = f.project_id
		  LEFT JOIN paliad.users own ON own.id = f.created_by
		 WHERE f.status = 'pending'
		   AND ` + dateCond + `
		   AND (
		         f.created_by = $2
		      OR EXISTS (
		             SELECT 1 FROM paliad.project_teams pt
		              WHERE pt.user_id = $2
		                AND pt.responsibility = 'lead'
		                AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
		         )
		      OR own.escalation_contact_id = $2
		      OR ($3 = TRUE AND own.escalation_contact_id IS NULL)
		   )
		 ORDER BY f.due_date ASC, f.id ASC`

	rows := []digestRow{}
	if err := s.db.SelectContext(ctx, &rows, query, today, u.ID, isGlobalAdmin); err != nil {
		return nil, fmt.Errorf("select deadlines: %w", err)
	}

	out := make([]digestRow, 0, len(rows))
	for _, r := range rows {
		cat := categorize(r.DueDate, today, offset)
		if cat == "" {
			continue
		}
		isOwner := r.OwnerID == u.ID
		ownerHasOverride := r.OwnerEscalationContactID != nil
		isEscalationContact := ownerHasOverride && *r.OwnerEscalationContactID == u.ID
		if !visibleForCategory(cat, slot, isOwner, r.IsLead, isGlobalAdmin, isEscalationContact, ownerHasOverride) {
			continue
		}
		// Honour the per-category email_preferences toggle if the user
		// opted out of that specific kind.
		if !reminderEnabled(u.EmailPreferences, "deadline_reminders."+cat) {
			continue
		}
		r.Category = cat
		out = append(out, r)
	}
	return out, nil
}

// categorize buckets a deadline into "overdue" / "due_today" / "due_warning"
// based on how its due_date relates to today + the user's warning offset,
// all interpreted in the user's local timezone. Anything that isn't one of
// the three returns "" (skip).
//
// Postgres DATE columns scan as time.Time at UTC midnight. We compare on
// y/m/d only to avoid offset artefacts.
func categorize(dueDate, today time.Time, warningOffsetDays int) string {
	d := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, today.Location())
	t := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())

	switch {
	case d.Before(t):
		return "overdue"
	case d.Equal(t):
		return "due_today"
	case d.Equal(t.AddDate(0, 0, warningOffsetDays)):
		return "due_warning"
	}
	return ""
}

// visibleForCategory enforces the per-category recipient rules from the
// design doc:
//
//   * due_warning: owner OR lead (morning only — caller already filtered
//     by slot in the SQL date predicate).
//   * due_today, morning: owner OR lead.
//   * due_today, evening (DRINGEND): owner OR lead OR escalation channel.
//   * overdue: owner OR escalation channel (NOT lead — system failure,
//     escalate past the team).
//
// The "escalation channel" is the owner's escalation_contact_id when
// non-NULL, else the firm's global_admins (fallback). When a contact is
// set, global_admins are *deliberately excluded* so escalation does not
// fan out to the whole admin team — that's the whole point of the
// override (t-paliad-066).
func visibleForCategory(
	category, slot string,
	isOwner, isLead, isGlobalAdmin, isEscalationContact, ownerHasEscalationOverride bool,
) bool {
	// When the owner set a specific escalation contact, the global-admin
	// fallback is suppressed — only the named contact (and the owner /
	// leads, who get notified for their own reasons) are on the escalation
	// channel.
	escalationVisible := func() bool {
		if ownerHasEscalationOverride {
			return isEscalationContact
		}
		return isGlobalAdmin
	}
	switch category {
	case "due_warning":
		return isOwner || isLead
	case "due_today":
		if slot == "evening" {
			return isOwner || isLead || escalationVisible()
		}
		return isOwner || isLead
	case "overdue":
		return isOwner || escalationVisible()
	}
	return false
}

// reminderEnabled reports whether the user's email_preferences allow a given
// reminder key. A missing key or empty object means on (opt-out) — that
// preserves the behaviour users had before the settings page shipped.
//
// Two paths share this helper:
//   * the master switch ("deadline_reminders") — if explicitly false, no
//     digest email goes out at all.
//   * per-category keys ("deadline_reminders.overdue", "…due_today",
//     "…due_warning") — if explicitly false, that category's rows are
//     dropped from the digest. The legacy keys ("…tomorrow", "…weekly")
//     are no longer queried.
func reminderEnabled(raw json.RawMessage, key string) bool {
	if len(raw) == 0 {
		return true
	}
	prefs := map[string]any{}
	if err := json.Unmarshal(raw, &prefs); err != nil {
		// Corrupt JSON in the DB shouldn't silence reminders — err on the
		// side of sending so users aren't dropped because of bad data.
		return true
	}
	// Master gate: an explicit false on "deadline_reminders" wins.
	if v, ok := prefs["deadline_reminders"]; ok {
		if b, ok := v.(bool); ok && !b {
			return false
		}
	}
	// Caller-specific gate.
	if key != "deadline_reminders" {
		if v, ok := prefs[key]; ok {
			if b, ok := v.(bool); ok && !b {
				return false
			}
		}
	}
	return true
}

// inSlot reports whether the user's local hour-of-day matches the configured
// hour for the given slot. The minute is ignored — the ticker fires hourly,
// so we only compare hour granularity.
//
// A bad/empty timezone returns false (skip the user this tick) and logs an
// error — historically we silently fell back to UTC, which masked the
// alpine-container tzdata bug (t-paliad-064): in production
// time.LoadLocation("Europe/Berlin") errored because the runtime image
// shipped no /usr/share/zoneinfo, the silent UTC fallback fired, and
// reminder_morning_time=09:00 matched at 09:00 UTC = 11:00 Berlin. The
// `_ "time/tzdata"` import in cmd/server/main.go makes lookups work without
// OS tzdata; this function now refuses to send rather than guessing.
//
// An unparseable HH:MM string still falls back to the column default
// (09:00 / 16:00) — that's a separate, recoverable input and dropping the
// user for it would be punitive.
func inSlot(now time.Time, tz, morning, evening, slot string) bool {
	loc, err := time.LoadLocation(tz)
	if err != nil {
		slog.Error("reminder: cannot load timezone, skipping user this tick",
			"tz", tz, "slot", slot, "error", err)
		return false
	}
	local := now.In(loc)

	target := morning
	if slot == "evening" {
		target = evening
	}
	hour, ok := parseHour(target)
	if !ok {
		// Fall back to defaults rather than dropping the user entirely.
		if slot == "evening" {
			hour = 16
		} else {
			hour = 9
		}
	}
	return local.Hour() == hour
}

// slotPastDueToday reports whether the user's slot hour for today has
// already arrived (or is currently in progress) in the user's timezone.
// It's the relaxed sibling of inSlot: inSlot uses ==, slotPastDueToday
// uses >=. Used by runStartupCatchUp to redeliver slots that the regular
// tick missed because a redeploy moved the tick out of the slot hour.
//
// The slot_date dedup (paliad.reminder_log.slot/slot_date with partial
// UNIQUE INDEX) prevents this from double-firing if the slot already ran
// earlier today, so it's safe to run this opportunistically on every boot.
//
// A bad/empty timezone returns false (skip this user) — same defensive
// stance inSlot took for the same reason (t-paliad-064: alpine tzdata).
func slotPastDueToday(now time.Time, tz, morning, evening, slot string) bool {
	loc, err := time.LoadLocation(tz)
	if err != nil {
		slog.Error("reminder: catch-up cannot load timezone, skipping user",
			"tz", tz, "slot", slot, "error", err)
		return false
	}
	local := now.In(loc)

	target := morning
	if slot == "evening" {
		target = evening
	}
	hour, ok := parseHour(target)
	if !ok {
		if slot == "evening" {
			hour = 16
		} else {
			hour = 9
		}
	}
	return local.Hour() >= hour
}

// parseHour pulls the hour out of an "HH:MM" or "HH:MM:SS" string. Returns
// (0, false) on malformed input — callers fall back to the column defaults.
func parseHour(s string) (int, bool) {
	for _, layout := range []string{"15:04:05", "15:04"} {
		if t, err := time.Parse(layout, s); err == nil {
			return t.Hour(), true
		}
	}
	return 0, false
}

// hasDigestSent checks the slot-level dedup row (migration 025). The
// partial unique index on (user_id, slot, slot_date) WHERE slot IS NOT NULL
// makes this a single index lookup.
func (s *ReminderService) hasDigestSent(ctx context.Context, userID uuid.UUID, slot string, slotDate time.Time) (bool, error) {
	var exists bool
	err := s.db.GetContext(ctx, &exists,
		`SELECT EXISTS (
		     SELECT 1 FROM paliad.reminder_log
		      WHERE user_id = $1
		        AND slot = $2
		        AND slot_date = $3
		 )`, userID, slot, slotDate)
	return exists, err
}

// logDigestSend writes one row per (user, slot, local-date). The unique
// index serialises concurrent ticks for the same user/slot — if a stray
// double-tick raced past the dedup check, the second insert errors and the
// caller drops the second send.
func (s *ReminderService) logDigestSend(ctx context.Context, userID uuid.UUID, slot string, slotDate time.Time) error {
	reminderType := slot + "_digest"
	if _, err := s.db.ExecContext(ctx,
		`INSERT INTO paliad.reminder_log (user_id, reminder_type, slot, slot_date)
		 VALUES ($1, $2, $3, $4)`,
		userID, reminderType, slot, slotDate,
	); err != nil {
		return fmt.Errorf("insert reminder_log: %w", err)
	}
	return nil
}

// deliverDigest renders deadline_digest.html for u and sends one email.
// Caller is responsible for the dedup row (we want it to land only on
// successful send).
func (s *ReminderService) deliverDigest(u models.User, slot string, rows []digestRow) error {
	lang := "de"
	if u.Lang == "en" {
		lang = "en"
	}

	// Bucket rows by category for the template. Within a category, rows
	// arrive sorted by due_date already (SQL ORDER BY).
	//
	// Pending-approval rows (t-paliad-138) get a "[PENDING] " title prefix
	// so the recipient can't miss that the deadline is unverified — silence
	// on a pending change is the worst outcome for a 4-eye system.
	var overdue, dueToday, dueWarning []map[string]any
	pendingCount := 0
	for _, r := range rows {
		title := r.Title
		isPending := r.ApprovalStatus == ApprovalStatusPending
		if isPending {
			title = "[PENDING] " + title
			pendingCount++
		}
		item := map[string]any{
			"DueDate":          r.DueDate.Format("2006-01-02"),
			"Title":            title,
			"IsPending":        isPending,
			"ProjectReference": r.ProjectReference,
			"ProjectTitle":     r.ProjectTitle,
			"OwnerName":        r.OwnerName,
			"IsOtherOwner":     r.OwnerID != u.ID,
			"URL":              fmt.Sprintf("%s/deadlines/%s", s.baseURL, r.DeadlineID),
		}
		switch r.Category {
		case "overdue":
			overdue = append(overdue, item)
		case "due_today":
			dueToday = append(dueToday, item)
		case "due_warning":
			dueWarning = append(dueWarning, item)
		}
	}

	// Subject is rendered from the (deadline_digest, lang) template row by
	// MailService — see internal/services/email_template_service.go for the
	// SYSTEMAUSFALL/SYSTEM FAILURE conditional logic and the rationale for
	// keeping that framing in place. OpenTotal is precomputed for the
	// "Frist-Erinnerung: N offen" pluralisation in the subject template.
	data := map[string]any{
		"Slot":            slot,
		"IsEvening":       slot == "evening",
		"Overdue":         overdue,
		"DueToday":        dueToday,
		"DueWarning":      dueWarning,
		"OverdueCount":    len(overdue),
		"DueTodayCount":   len(dueToday),
		"DueWarningCount": len(dueWarning),
		"OpenTotal":       len(dueToday) + len(dueWarning),
		"DeadlinesURL":    fmt.Sprintf("%s/deadlines", s.baseURL),
		// PendingCount > 0 → templates can render a banner like
		// "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
		// /inbox" above the digest body. Available even when the
		// template doesn't currently use it (forward-compat, no
		// existing-template breakage).
		"PendingCount":    pendingCount,
		"InboxURL":        fmt.Sprintf("%s/inbox", s.baseURL),
	}
	return s.mail.SendTemplate(TemplateData{
		To:   u.Email,
		Lang: lang,
		Name: "deadline_digest",
		Data: data,
	})
}
