// ui_notifications.go — HTML form-driven handlers for the notification
// channel CRUD at /settings/notifications and the test-fire endpoint at
// POST /api/notifications/{id}/test.
//
// The settings shell currently has a single sub-tab (Notifications);
// the structure is designed to be extended with Users/Auth tabs later.
//
// Routes (wired in server.go):
//
//	GET  /settings                           → handleUISettings
//	GET  /settings/notifications             → handleUINotificationsList
//	GET  /settings/notifications/new         → handleUINotificationNewGet
//	POST /settings/notifications/new         → handleUINotificationNewPost
//	GET  /settings/notifications/{id}/edit   → handleUINotificationEditGet
//	POST /settings/notifications/{id}/edit   → handleUINotificationEditPost
//	POST /settings/notifications/{id}/delete → handleUINotificationDelete
//	POST /api/notifications/{id}/test        → handleAPINotificationTest
package http

import (
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	stdhttp "net/http"
	"net/mail"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/oklog/ulid/v2"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

// ── page models ──────────────────────────────────────────────────────────────

// settingsPage is the data fed to the settings shell template. The
// sub-tab body is embedded via the Channels slice so a single template
// layout works for both the list and the edit form.
type settingsPage struct {
	// ActiveTab is the settings sub-tab currently visible.
	ActiveTab string
	// Channels is the full list (list sub-tab).
	Channels []store.NotificationChannel
	// Form is populated when the operator is creating or editing a channel.
	Form *notificationForm
	// FormError is an inline error message for the channel form.
	FormError string
	// DeleteError is an inline error shown on the confirm-delete form.
	DeleteError string
}

// notificationForm holds the round-trip values for the channel
// create/edit form. Separate per-kind sub-structs mirror the template
// field groups; all fields are strings so the template never has to
// handle nil.
type notificationForm struct {
	// ID is the channel's ULID; empty for new.
	ID   string
	Kind string // webhook | ntfy | smtp
	Name string
	// Enabled maps to the enabled checkbox.
	Enabled bool
	// DefaultPriority applies to ntfy channels.
	DefaultPriority string

	// Webhook sub-fields.
	WebhookURL         string
	WebhookBearerToken string
	WebhookHeaderName  string
	WebhookHeaderValue string

	// Ntfy sub-fields.
	NtfyServerURL   string
	NtfyTopic       string
	NtfyAccessToken string
	NtfyUsername    string
	NtfyPassword    string

	// SMTP sub-fields.
	SMTPHost       string
	SMTPPort       string // string for form round-trip; validated to int on save
	SMTPEncryption string
	SMTPUsername   string
	// SMTPPassword is a write-only field: shown as placeholder on edit;
	// blank on submit means "keep the stored value".
	SMTPPassword string
	SMTPFrom     string
	SMTPTo       string
}

// ── internal helpers ──────────────────────────────────────────────────────────

// loadSettingsPage fetches the channel list and returns the base page model.
func (s *Server) loadSettingsPage(r *stdhttp.Request) (*settingsPage, error) {
	chans, err := s.deps.Store.ListNotificationChannels(r.Context())
	if err != nil {
		return nil, fmt.Errorf("list channels: %w", err)
	}
	return &settingsPage{
		ActiveTab: "notifications",
		Channels:  chans,
	}, nil
}

// renderSettingsPage renders the settings shell, setting HTTP 422 on
// validation failure (pass status=0 for the normal 200).
func (s *Server) renderSettingsPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, page *settingsPage, status int) {
	view := s.baseView(r, u)
	view.Title = "Settings · restic-manager"
	view.Active = "settings"
	view.Page = *page
	if status != 0 {
		w.WriteHeader(status)
	}
	if err := s.deps.UI.Render(w, "settings", view); err != nil {
		slog.Error("ui: render settings", "err", err)
	}
}

// encryptChannelConfig JSON-encodes cfg and AEAD-seals it with the
// channel-specific additional-data binding.
func (s *Server) encryptChannelConfig(id string, cfg any) ([]byte, error) {
	plain, err := json.Marshal(cfg)
	if err != nil {
		return nil, fmt.Errorf("marshal config: %w", err)
	}
	enc, err := s.deps.AEAD.Encrypt(plain, []byte("notification-channel:"+id))
	if err != nil {
		return nil, fmt.Errorf("encrypt config: %w", err)
	}
	return []byte(enc), nil
}

// decryptChannelConfig decrypts the AEAD blob and unmarshals it into dst.
func (s *Server) decryptChannelConfig(ch store.NotificationChannel, dst any) error {
	plain, err := s.deps.AEAD.Decrypt(string(ch.Config), []byte("notification-channel:"+ch.ID))
	if err != nil {
		return fmt.Errorf("decrypt: %w", err)
	}
	return json.Unmarshal(plain, dst)
}

// firstNonEmpty returns the first non-empty (after TrimSpace) value in
// vals, or "". Used for fields like `name` that appear once per per-kind
// sub-form: only the visible kind's input is filled in, so PostForm.Get
// (which returns the first regardless of emptiness) would lose the
// actual value when the user edits the second or third kind.
func firstNonEmpty(vals []string) string {
	for _, v := range vals {
		if strings.TrimSpace(v) != "" {
			return v
		}
	}
	return ""
}

// formHasValue reports whether vals contains want. Used for hidden+checkbox
// pairs (e.g. <input hidden name=x value=0> + <input checkbox name=x value=1>)
// where r.PostForm.Get returns the first ("0") even when the checkbox is
// ticked, so we have to scan the slice instead.
func formHasValue(vals []string, want string) bool {
	for _, v := range vals {
		if v == want {
			return true
		}
	}
	return false
}

// formFromRequest parses the common + per-kind fields from a POST form.
// The caller must have already called r.ParseForm().
func formFromRequest(r *stdhttp.Request) *notificationForm {
	f := &notificationForm{
		Kind:            strings.TrimSpace(r.PostForm.Get("kind")),
		Name:            strings.TrimSpace(firstNonEmpty(r.PostForm["name"])),
		Enabled:         formHasValue(r.PostForm["enabled"], "1"),
		DefaultPriority: strings.TrimSpace(r.PostForm.Get("default_priority")),

		WebhookURL:         strings.TrimSpace(r.PostForm.Get("webhook_url")),
		WebhookBearerToken: r.PostForm.Get("webhook_bearer_token"),
		WebhookHeaderName:  strings.TrimSpace(r.PostForm.Get("webhook_header_name")),
		WebhookHeaderValue: r.PostForm.Get("webhook_header_value"),

		NtfyServerURL:   strings.TrimSpace(r.PostForm.Get("ntfy_server_url")),
		NtfyTopic:       strings.TrimSpace(r.PostForm.Get("ntfy_topic")),
		NtfyAccessToken: r.PostForm.Get("ntfy_access_token"),
		NtfyUsername:    strings.TrimSpace(r.PostForm.Get("ntfy_username")),
		NtfyPassword:    r.PostForm.Get("ntfy_password"),

		SMTPHost:       strings.TrimSpace(r.PostForm.Get("smtp_host")),
		SMTPPort:       strings.TrimSpace(r.PostForm.Get("smtp_port")),
		SMTPEncryption: strings.TrimSpace(r.PostForm.Get("smtp_encryption")),
		SMTPUsername:   strings.TrimSpace(r.PostForm.Get("smtp_username")),
		SMTPPassword:   r.PostForm.Get("smtp_password"),
		SMTPFrom:       strings.TrimSpace(r.PostForm.Get("smtp_from")),
		SMTPTo:         strings.TrimSpace(r.PostForm.Get("smtp_to")),
	}
	if f.Kind == "" {
		f.Kind = "webhook"
	}
	return f
}

// validateForm validates the common + per-kind fields. Returns a
// non-empty string on the first validation error found.
func validateForm(f *notificationForm) string {
	if f.Name == "" {
		return "Name is required."
	}
	if len(f.Name) > 100 {
		return "Name must be 100 characters or fewer."
	}
	switch f.Kind {
	case "webhook":
		if f.WebhookURL == "" {
			return "Webhook URL is required."
		}
		u, err := url.Parse(f.WebhookURL)
		if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
			return "Webhook URL must be a valid http(s) URL."
		}
	case "ntfy":
		if f.NtfyServerURL != "" {
			u, err := url.Parse(f.NtfyServerURL)
			if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
				return "Ntfy server URL must be a valid http(s) URL."
			}
		}
		if f.NtfyTopic == "" {
			return "Ntfy topic is required."
		}
	case "smtp":
		if f.SMTPHost == "" {
			return "SMTP host is required."
		}
		port, err := strconv.Atoi(f.SMTPPort)
		if err != nil || port < 1 || port > 65535 {
			return "SMTP port must be a number between 1 and 65535."
		}
		switch f.SMTPEncryption {
		case "starttls", "tls", "none":
		default:
			return "SMTP encryption must be starttls, tls, or none."
		}
		if f.SMTPFrom == "" {
			return "SMTP From address is required."
		}
		if _, err := mail.ParseAddress(f.SMTPFrom); err != nil {
			return "SMTP From is not a valid email address."
		}
		if f.SMTPTo == "" {
			return "SMTP To address is required."
		}
		if _, err := mail.ParseAddress(f.SMTPTo); err != nil {
			return "SMTP To is not a valid email address."
		}
	default:
		return "Kind must be webhook, ntfy, or smtp."
	}
	return ""
}

// buildConfig constructs the per-kind notification config struct from f.
// For edit (existing != nil), blank password fields fall back to the
// stored value so the operator can save other fields without re-typing
// the credential.
func buildConfig(f *notificationForm, existing any) (any, error) {
	switch f.Kind {
	case "webhook":
		cfg := notification.WebhookConfig{
			URL:         f.WebhookURL,
			BearerToken: f.WebhookBearerToken,
			HeaderName:  f.WebhookHeaderName,
			HeaderValue: f.WebhookHeaderValue,
		}
		if existing != nil {
			ex, ok := existing.(*notification.WebhookConfig)
			if ok && cfg.BearerToken == "" {
				cfg.BearerToken = ex.BearerToken
			}
		}
		return cfg, nil

	case "ntfy":
		cfg := notification.NtfyConfig{
			ServerURL:   f.NtfyServerURL,
			Topic:       f.NtfyTopic,
			AccessToken: f.NtfyAccessToken,
			Username:    f.NtfyUsername,
			Password:    f.NtfyPassword,
		}
		if existing != nil {
			if ex, ok := existing.(*notification.NtfyConfig); ok {
				// Blank password on edit means "keep stored value"
				// — same write-only treatment as smtp_password.
				if cfg.AccessToken == "" {
					cfg.AccessToken = ex.AccessToken
				}
				if cfg.Password == "" {
					cfg.Password = ex.Password
				}
			}
		}
		return cfg, nil

	case "smtp":
		port, _ := strconv.Atoi(f.SMTPPort)
		cfg := notification.SMTPConfig{
			Host:       f.SMTPHost,
			Port:       port,
			Encryption: f.SMTPEncryption,
			Username:   f.SMTPUsername,
			Password:   f.SMTPPassword,
			From:       f.SMTPFrom,
			To:         f.SMTPTo,
		}
		if existing != nil {
			ex, ok := existing.(*notification.SMTPConfig)
			if ok && cfg.Password == "" {
				cfg.Password = ex.Password
			}
		}
		return cfg, nil
	}
	return nil, fmt.Errorf("unknown kind %q", f.Kind)
}

// ── UI handlers ───────────────────────────────────────────────────────────────

// handleUISettings renders the settings shell (defaults to the
// Notifications sub-tab in v1).
func (s *Server) handleUISettings(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	s.handleUINotificationsList(w, r)
}

// handleUINotificationsList renders the channel list under the
// Notifications sub-tab.
func (s *Server) handleUINotificationsList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	page, err := s.loadSettingsPage(r)
	if err != nil {
		slog.Error("ui settings: load", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	s.renderSettingsPage(w, r, u, page, 0)
}

// handleUINotificationNewGet renders the kind picker + empty form.
// The ?kind= query param pre-selects the visible per-kind fields.
func (s *Server) handleUINotificationNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	page, err := s.loadSettingsPage(r)
	if err != nil {
		slog.Error("ui settings: load", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	kind := r.URL.Query().Get("kind")
	if kind == "" {
		kind = "webhook"
	}
	page.Form = &notificationForm{Kind: kind}
	s.renderSettingsPage(w, r, u, page, 0)
}

// handleUINotificationNewPost validates and creates a new channel, then
// redirects to the list. Re-renders the form with an error banner on
// validation failure.
func (s *Server) handleUINotificationNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}

	f := formFromRequest(r)
	if errMsg := validateForm(f); errMsg != "" {
		page, _ := s.loadSettingsPage(r)
		if page == nil {
			page = &settingsPage{ActiveTab: "notifications"}
		}
		page.Form = f
		page.FormError = errMsg
		s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity)
		return
	}

	id := ulid.Make().String()
	cfg, err := buildConfig(f, nil)
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	enc, err := s.encryptChannelConfig(id, cfg)
	if err != nil {
		slog.Error("ui notifications: encrypt", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	now := time.Now().UTC()
	var dp *string
	if f.DefaultPriority != "" {
		dp = &f.DefaultPriority
	}
	ch := store.NotificationChannel{
		ID:              id,
		Kind:            f.Kind,
		Name:            f.Name,
		Enabled:         f.Enabled,
		Config:          enc,
		DefaultPriority: dp,
		CreatedAt:       now,
		UpdatedAt:       now,
	}
	if err := s.deps.Store.CreateNotificationChannel(r.Context(), ch); err != nil {
		slog.Error("ui notifications: create", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID:         ulid.Make().String(),
		UserID:     &u.ID,
		Actor:      "user",
		Action:     "notification_channel.created",
		TargetKind: ptr("notification_channel"),
		TargetID:   &id,
		TS:         now,
	})
	stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}

// handleUINotificationEditGet fetches a channel, decrypts its config,
// and renders the edit form with values pre-filled.
func (s *Server) handleUINotificationEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	channelID := chi.URLParam(r, "id")
	ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui notifications: get", "id", channelID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	f := &notificationForm{
		ID:      ch.ID,
		Kind:    ch.Kind,
		Name:    ch.Name,
		Enabled: ch.Enabled,
	}
	if ch.DefaultPriority != nil {
		f.DefaultPriority = *ch.DefaultPriority
	}

	switch ch.Kind {
	case "webhook":
		var cfg notification.WebhookConfig
		if err := s.decryptChannelConfig(*ch, &cfg); err == nil {
			f.WebhookURL = cfg.URL
			// BearerToken and custom headers: don't echo plaintext — shown
			// via placeholder text in the template.
			f.WebhookHeaderName = cfg.HeaderName
			// HeaderValue and BearerToken are write-only — left blank
			// so the placeholder "stored, leave blank to keep" shows.
		}
	case "ntfy":
		var cfg notification.NtfyConfig
		if err := s.decryptChannelConfig(*ch, &cfg); err == nil {
			f.NtfyServerURL = cfg.ServerURL
			f.NtfyTopic = cfg.Topic
			f.NtfyUsername = cfg.Username
			// AccessToken and Password are write-only.
		}
	case "smtp":
		var cfg notification.SMTPConfig
		if err := s.decryptChannelConfig(*ch, &cfg); err == nil {
			f.SMTPHost = cfg.Host
			f.SMTPPort = strconv.Itoa(cfg.Port)
			f.SMTPEncryption = cfg.Encryption
			f.SMTPUsername = cfg.Username
			// Password is write-only — left blank.
			f.SMTPFrom = cfg.From
			f.SMTPTo = cfg.To
		}
	}

	page, err := s.loadSettingsPage(r)
	if err != nil {
		slog.Error("ui settings: load", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	page.Form = f
	s.renderSettingsPage(w, r, u, page, 0)
}

// handleUINotificationEditPost validates the edit form, merges new
// values onto the existing config (preserving blanked-out secrets),
// re-encrypts, and updates the channel row.
func (s *Server) handleUINotificationEditPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	channelID := chi.URLParam(r, "id")
	ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui notifications: get for edit", "id", channelID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	f := formFromRequest(r)
	f.ID = ch.ID

	if errMsg := validateForm(f); errMsg != "" {
		page, _ := s.loadSettingsPage(r)
		if page == nil {
			page = &settingsPage{ActiveTab: "notifications"}
		}
		page.Form = f
		page.FormError = errMsg
		s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity)
		return
	}

	// Decrypt existing config so blank password fields can fall back
	// to the stored values.
	var existingCfg any
	switch ch.Kind {
	case "webhook":
		var cfg notification.WebhookConfig
		if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil {
			existingCfg = &cfg
		}
	case "ntfy":
		var cfg notification.NtfyConfig
		if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil {
			existingCfg = &cfg
		}
	case "smtp":
		var cfg notification.SMTPConfig
		if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil {
			existingCfg = &cfg
		}
	}

	newCfg, err := buildConfig(f, existingCfg)
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	enc, err := s.encryptChannelConfig(ch.ID, newCfg)
	if err != nil {
		slog.Error("ui notifications: re-encrypt", "id", ch.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	now := time.Now().UTC()
	var dp *string
	if f.DefaultPriority != "" {
		dp = &f.DefaultPriority
	}
	updated := store.NotificationChannel{
		ID:              ch.ID,
		Kind:            f.Kind,
		Name:            f.Name,
		Enabled:         f.Enabled,
		Config:          enc,
		DefaultPriority: dp,
		CreatedAt:       ch.CreatedAt,
		UpdatedAt:       now,
	}
	if err := s.deps.Store.UpdateNotificationChannel(r.Context(), updated); err != nil {
		slog.Error("ui notifications: update", "id", ch.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID:         ulid.Make().String(),
		UserID:     &u.ID,
		Actor:      "user",
		Action:     "notification_channel.updated",
		TargetKind: ptr("notification_channel"),
		TargetID:   &ch.ID,
		TS:         now,
	})
	stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}

// handleUINotificationDelete implements the typed-confirm pattern:
// the operator must type the channel name to proceed. On match,
// DeleteNotificationChannel + audit row + redirect. On mismatch,
// re-render with an error.
func (s *Server) handleUINotificationDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	channelID := chi.URLParam(r, "id")
	ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui notifications: get for delete", "id", channelID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	confirm := strings.TrimSpace(r.PostForm.Get("confirm_name"))
	if confirm != ch.Name {
		page, _ := s.loadSettingsPage(r)
		if page == nil {
			page = &settingsPage{ActiveTab: "notifications"}
		}
		page.Form = &notificationForm{ID: ch.ID, Kind: ch.Kind, Name: ch.Name}
		page.DeleteError = "Typed name did not match — deletion aborted."
		s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity)
		return
	}

	if err := s.deps.Store.DeleteNotificationChannel(r.Context(), ch.ID); err != nil {
		slog.Error("ui notifications: delete", "id", ch.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID:         ulid.Make().String(),
		UserID:     &u.ID,
		Actor:      "user",
		Action:     "notification_channel.deleted",
		TargetKind: ptr("notification_channel"),
		TargetID:   &ch.ID,
		TS:         time.Now().UTC(),
	})
	stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}

// handleUINotificationToggle flips the enabled flag for one channel
// and re-renders the row. Wired to the inline toggle in the channel
// list so operators don't need to enter the edit form just to flip a
// channel on or off. HTMX-aware: returns just the toggle fragment when
// the request carries HX-Request, otherwise redirects back to the list.
func (s *Server) handleUINotificationToggle(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	channelID := chi.URLParam(r, "id")
	ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui notifications: get for toggle", "id", channelID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	now := time.Now().UTC()
	want := !ch.Enabled
	if err := s.deps.Store.SetNotificationChannelEnabled(r.Context(), ch.ID, want, now); err != nil {
		slog.Error("ui notifications: set enabled", "id", ch.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID:         ulid.Make().String(),
		UserID:     &u.ID,
		Actor:      "user",
		Action:     "notification_channel.toggled",
		TargetKind: ptr("notification_channel"),
		TargetID:   &ch.ID,
		TS:         now,
	})
	if r.Header.Get("HX-Request") == "true" {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		if want {
			_, _ = w.Write([]byte(`<span class="toggle on" hx-post="/settings/notifications/` + ch.ID + `/toggle" hx-target="this" hx-swap="outerHTML" onclick="event.stopPropagation()" style="cursor:pointer"></span>`))
		} else {
			_, _ = w.Write([]byte(`<span class="toggle" hx-post="/settings/notifications/` + ch.ID + `/toggle" hx-target="this" hx-swap="outerHTML" onclick="event.stopPropagation()" style="cursor:pointer"></span>`))
		}
		return
	}
	stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}

// ── API handler ───────────────────────────────────────────────────────────────

// testResultFragment is the JSON body returned by handleAPINotificationTest.
type testResultFragment struct {
	OK         bool    `json:"ok"`
	LatencyMS  int     `json:"latency_ms"`
	StatusCode *int    `json:"status_code,omitempty"`
	Error      *string `json:"error,omitempty"`
}

// handleAPINotificationTest fires a single synthetic test payload
// through the named channel via Hub.DispatchOne and returns a JSON
// result. The test button in the UI posts here and renders the
// green/red pill from the response.
func (s *Server) handleAPINotificationTest(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	if s.deps.NotificationHub == nil {
		writeJSONError(w, stdhttp.StatusServiceUnavailable, "hub_not_ready",
			"notification hub not initialised")
		return
	}
	channelID := chi.URLParam(r, "id")
	if _, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			writeJSONError(w, stdhttp.StatusNotFound, "not_found", "channel not found")
			return
		}
		slog.Error("api: notification test: get channel", "id", channelID, "err", err)
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
		return
	}

	// AlertID is intentionally left empty for test notifications: the
	// notification_log.alert_id column has a FK to alerts.id, and no
	// real alert exists for a synthetic test fire. The hub leaves the
	// column NULL when AlertID is empty.
	payload := notification.Payload{
		Event:    notification.EventTest,
		Severity: "info",
		Kind:     "test_notification",
		HostName: "(test)",
		Message:  "Test from restic-manager — channel is working.",
		RaisedAt: time.Now().UTC(),
	}

	entry, err := s.deps.NotificationHub.DispatchOne(r.Context(), channelID, payload)
	if err != nil {
		slog.Error("api: notification test: dispatch", "id", channelID, "err", err)
		errStr := err.Error()
		writeJSON(w, stdhttp.StatusOK, testResultFragment{
			OK:    false,
			Error: &errStr,
		})
		return
	}

	res := testResultFragment{OK: entry.OK, StatusCode: entry.StatusCode}
	if entry.LatencyMS != nil {
		res.LatencyMS = *entry.LatencyMS
	}
	if entry.Error != nil {
		res.Error = entry.Error
	}
	writeJSON(w, stdhttp.StatusOK, res)
}
