package http

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"errors"
	"html/template"
	"io/fs"
	"log/slog"
	"math"
	stdhttp "net/http"
	"net/url"
	"sort"
	"strings"
	"time"

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

	"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/restic"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/web/sparkline"
	"gitea.dcglab.co.uk/steve/restic-manager/web"
)

// ----- static assets (Tailwind CSS, future favicon, etc) -------------

// staticHandler serves files embedded under web/static/ at /static/*.
// Returns 404 for anything missing rather than the fs default 500.
func staticHandler() stdhttp.Handler {
	sub, err := fs.Sub(web.FS, "static")
	if err != nil {
		// Embed.FS panics live at compile time; if Sub fails the binary
		// is genuinely broken — surface it loudly.
		panic("web: static subtree missing: " + err.Error())
	}
	return stdhttp.StripPrefix("/static/", stdhttp.FileServer(stdhttp.FS(sub)))
}

// ----- session helpers ------------------------------------------------

// sessionUser resolves the request's session cookie to a User, or
// (nil, nil) if the cookie is missing/expired/invalid. A non-nil
// error means an underlying store failure; treat that as 500.
func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
	c, err := r.Cookie(sessionCookieName)
	if err != nil {
		// Missing or invalid cookie just means the caller isn't logged
		// in — that's a normal state, not a server error. Return
		// (nil, nil) so callers can decide between "redirect to login"
		// and "treat as anonymous".
		return nil, nil //nolint:nilerr
	}
	sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
	if err != nil {
		// Treat "not found" / "expired" as "no session", not as fatal.
		if errors.Is(err, store.ErrNotFound) {
			return nil, nil
		}
		return nil, err
	}
	u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			return nil, nil
		}
		return nil, err
	}
	if u.DisabledAt != nil {
		_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
		return nil, nil
	}
	return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil
}

// requireUIUser resolves the session and 303-redirects to /login if
// there isn't one. Returns nil + emits the redirect when unauthed.
// (HTML twin of jobs.go's API-style requireUser, which returns 401.)
func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui.User {
	u, err := s.sessionUser(r)
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return nil
	}
	if u == nil {
		stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
		return nil
	}
	return u
}

// baseView populates the fields the nav partial needs on every
// authenticated page. Every UI page sits under the dashboard primary
// nav today; if a future page lives under a different primary nav
// tab (e.g. Settings, Audit), accept an Active arg again.
//
// OpenAlerts is populated via a quick store count so the nav badge
// stays current on every page load without requiring a page-specific
// store call.
func (s *Server) baseView(r *stdhttp.Request, u *ui.User) ui.ViewData {
	view := ui.ViewData{
		User:    u,
		Active:  "dashboard",
		Version: s.version(),
	}

	// Populate OpenAlerts from the store so the nav badge shows the
	// current count on every page.
	if open, err := s.deps.Store.ListAlerts(r.Context(), store.AlertFilter{Status: "open"}); err == nil {
		view.OpenAlerts = len(open)
	}

	return view
}

// version returns the binary's build version — passed in via Deps so
// cmd/server's `var version` ends up here.
func (s *Server) version() string {
	if s.deps.Version != "" {
		return s.deps.Version
	}
	return "dev"
}

// ----- handlers -------------------------------------------------------

// dashboardPage is the data the dashboard template renders against.
type dashboardPage struct {
	Hosts         []dashboardHostRow
	HostCount     int // unfiltered fleet size
	ShownCount    int // after every active filter
	Summary       store.FleetSummary
	PendingHosts  []store.PendingHost // announce-and-approve queue (P2-18d)
	CritOpenCount int
	// Tag filter state. ActiveTag is the chip currently selected
	// ("" = all). KnownTags is the full set of tags in use across
	// the fleet, used to render the chip-row.
	ActiveTag string
	KnownTags []string

	// Filter / sort URL state (NS-04). Round-tripped through query
	// string so a bookmarked / shared dashboard URL is durable, and
	// passed back to the template so the form inputs and column
	// header sort-arrows render with current state.
	Filter dashboardFilter
	// RefreshURL is the same dashboard URL with all current filters
	// pinned, used by the htmx live-poll trigger to refetch the
	// table without flashing the surrounding chrome.
	RefreshURL string
	// SortURL is a per-column URL builder: passing a column key
	// returns the URL that sorts by that column (toggling direction
	// when it's already active). Pre-computed so the template stays
	// dumb.
	SortURL map[string]string
	// UpdatesBehind is the count of online hosts whose agent_version
	// trails the server. Surfaces as the dashboard "N hosts behind"
	// hero tile and links to ?updates=behind.
	UpdatesBehind int
}

// dashboardFilter holds the parsed query-string filter state.
type dashboardFilter struct {
	Search     string // hostname substring match (case-insensitive)
	Status     string // "" | "online" | "offline" | "never_seen"
	RepoStatus string // "" | "unknown" | "ready" | "init_failed"
	Tag        string // mirrors ActiveTag for round-trip on links
	Sort       string // column key (see sortDashboard)
	Dir        string // "asc" | "desc"
	// Updates narrows to hosts whose agent is behind the server's
	// version. Only valid value today is "behind"; empty means no
	// filter.
	Updates string
}

// dashboardHostRow carries a host plus the per-row Run-now decision
// the host_row partial needs. The decision is computed server-side
// once per render rather than recomputed in the template.
type dashboardHostRow struct {
	Host store.Host
	// RunAllScheduleID is the ID of the single schedule that covers
	// every source group on the host. Empty when zero or 2+ schedules
	// match — in that case the row shows "Open →" instead of a Run-now
	// button (the operator picks per-group from the host detail).
	RunAllScheduleID string
	// NextRun is the next-fire time of RunAllScheduleID (when set),
	// computed server-side from its cron. nil otherwise.
	NextRun *time.Time
	// UpdateAvailable is true when the host's agent has connected at
	// least once AND its agent_version differs from the server's. Used
	// by the host_row partial to render the update-available chip.
	UpdateAvailable bool
	// TargetVersion is the server's build version, surfaced in the
	// chip's tooltip and label.
	TargetVersion string
	// RepoSparklineSVG is a server-rendered inline SVG showing the
	// 30-day repo-size trend. Empty-state SVG (em-dash) is returned
	// when no history rows exist for the host.
	RepoSparklineSVG template.HTML
}

// pickRunAllSchedule returns the ID of the single schedule whose
// source-group set ⊇ every source group on the host. Returns "" when
// zero or 2+ such "covering" schedules exist (operator-disambiguation
// belongs on the host detail, not the dashboard one-click).
func pickRunAllSchedule(scheds []store.Schedule, groups []store.SourceGroup) string {
	if len(groups) == 0 || len(scheds) == 0 {
		return ""
	}
	groupIDs := make(map[string]struct{}, len(groups))
	for _, g := range groups {
		groupIDs[g.ID] = struct{}{}
	}
	matched := ""
	for _, sc := range scheds {
		if !sc.Enabled {
			continue
		}
		// Treat sc.SourceGroupIDs as a set; check it covers every group.
		got := make(map[string]struct{}, len(sc.SourceGroupIDs))
		for _, gid := range sc.SourceGroupIDs {
			got[gid] = struct{}{}
		}
		covers := true
		for gid := range groupIDs {
			if _, ok := got[gid]; !ok {
				covers = false
				break
			}
		}
		if !covers {
			continue
		}
		if matched != "" {
			// Two distinct covering schedules — ambiguous, bail out.
			return ""
		}
		matched = sc.ID
	}
	return matched
}

// handleUIDashboard is the root page. Auth-gated; falls through to
// /login if there is no session.
func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}

	allHosts, err := s.deps.Store.ListHosts(r.Context())
	if err != nil {
		slog.Error("ui dashboard: list hosts", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	// Parse query-string filter + sort (NS-04). The tag chip-row is
	// kept as ?tag= for backwards compat with existing bookmarks.
	filter := parseDashboardFilter(r.URL.Query())
	hosts := filterAndSortDashboardHosts(allHosts, filter)
	knownTags, _ := s.deps.Store.DistinctHostTags(r.Context())

	summary, err := s.deps.Store.FleetSummary(r.Context())
	if err != nil {
		slog.Error("ui dashboard: fleet summary", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	// Per-host: pick the single covering schedule (if any) so the row
	// can render a one-click Run-now where it's unambiguous. Two store
	// calls per host — fine at fleet sizes we care about.
	rows := make([]dashboardHostRow, 0, len(hosts))
	for _, h := range hosts {
		row := dashboardHostRow{
			Host:            h,
			TargetVersion:   version.Version,
			UpdateAvailable: h.AgentVersion != "" && h.AgentVersion != version.Version,
		}
		groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID)
		if gerr != nil {
			slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr)
		}
		scheds, serr := s.deps.Store.ListSchedulesByHost(r.Context(), h.ID)
		if serr != nil {
			slog.Warn("ui dashboard: list schedules", "host_id", h.ID, "err", serr)
		}
		row.RunAllScheduleID = pickRunAllSchedule(scheds, groups)
		if row.RunAllScheduleID != "" {
			for _, sc := range scheds {
				if sc.ID == row.RunAllScheduleID {
					if parsed, perr := cronParser.Parse(sc.CronExpr); perr == nil {
						n := parsed.Next(time.Now().UTC()).UTC()
						row.NextRun = &n
					}
					break
				}
			}
		}
		since := time.Now().UTC().AddDate(0, 0, -30)
		pts, herr := s.deps.Store.ListHostRepoStatsHistory(r.Context(), h.ID, since)
		if herr != nil {
			slog.Warn("ui dashboard: list repo history", "host_id", h.ID, "err", herr)
		}
		sparkPoints := make([]float64, len(pts))
		for i, p := range pts {
			if p.TotalSizeBytes == nil {
				sparkPoints[i] = math.NaN()
			} else {
				sparkPoints[i] = float64(*p.TotalSizeBytes)
			}
		}
		row.RepoSparklineSVG = sparkline.RenderSparkline(sparkPoints, 88, 20)
		rows = append(rows, row)
	}

	pending, perr := s.deps.Store.ListPendingHosts(r.Context(), time.Now().UTC())
	if perr != nil {
		slog.Warn("ui dashboard: list pending hosts", "err", perr)
	}

	critOpenCount := 0
	if crit, err := s.deps.Store.ListAlerts(r.Context(), store.AlertFilter{Status: "open", Severity: "critical"}); err == nil {
		critOpenCount = len(crit)
	}

	updatesBehind := 0
	for _, h := range allHosts {
		if h.Status == "online" && h.AgentVersion != "" && h.AgentVersion != version.Version {
			updatesBehind++
		}
	}

	view := s.baseView(r, u)
	view.Page = dashboardPage{
		Hosts:         rows,
		HostCount:     len(allHosts),
		ShownCount:    len(rows),
		Summary:       summary,
		PendingHosts:  pending,
		CritOpenCount: critOpenCount,
		ActiveTag:     filter.Tag,
		KnownTags:     knownTags,
		Filter:        filter,
		RefreshURL:    "/?" + filter.encode(),
		SortURL:       buildDashboardSortURLs(filter),
		UpdatesBehind: updatesBehind,
	}
	if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
		slog.Error("ui: render dashboard", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// parseDashboardFilter reads the query string into a dashboardFilter,
// normalising defaults (sort=name, dir=asc) so the rest of the
// pipeline doesn't have to special-case empty values.
func parseDashboardFilter(q url.Values) dashboardFilter {
	f := dashboardFilter{
		Search:     strings.TrimSpace(q.Get("q")),
		Status:     q.Get("status"),
		RepoStatus: q.Get("repo_status"),
		Tag:        q.Get("tag"),
		Sort:       q.Get("sort"),
		Dir:        q.Get("dir"),
		Updates:    q.Get("updates"),
	}
	if f.Sort == "" {
		f.Sort = "name"
	}
	if f.Dir != "asc" && f.Dir != "desc" {
		f.Dir = "asc"
	}
	return f
}

// encode rebuilds the filter as a URL-safe query string. Used for the
// live-refresh URL and for column-sort link composition.
func (f dashboardFilter) encode() string {
	v := url.Values{}
	if f.Search != "" {
		v.Set("q", f.Search)
	}
	if f.Status != "" {
		v.Set("status", f.Status)
	}
	if f.RepoStatus != "" {
		v.Set("repo_status", f.RepoStatus)
	}
	if f.Tag != "" {
		v.Set("tag", f.Tag)
	}
	if f.Sort != "" && f.Sort != "name" {
		v.Set("sort", f.Sort)
	}
	if f.Dir != "" && f.Dir != "asc" {
		v.Set("dir", f.Dir)
	}
	if f.Updates != "" {
		v.Set("updates", f.Updates)
	}
	return v.Encode()
}

// filterAndSortDashboardHosts narrows a host list by the active
// filter dimensions, then sorts it by the chosen column/direction.
// Filter precedence: search ∧ status ∧ repo_status ∧ tag — every
// active filter has to match. Sort runs after filtering.
func filterAndSortDashboardHosts(hosts []store.Host, f dashboardFilter) []store.Host {
	out := make([]store.Host, 0, len(hosts))
	q := strings.ToLower(f.Search)
	for _, h := range hosts {
		if q != "" && !strings.Contains(strings.ToLower(h.Name), q) {
			continue
		}
		if f.Status != "" {
			switch f.Status {
			case "online", "offline":
				if h.Status != f.Status {
					continue
				}
			case "never_seen":
				if h.LastSeenAt != nil {
					continue
				}
			}
		}
		if f.RepoStatus != "" {
			// Backward compatibility: rows pre-NS-03 have an empty
			// status string in memory if loaded before the migration
			// scan added the column; treat that as "unknown".
			rs := h.RepoStatus
			if rs == "" {
				rs = "unknown"
			}
			if rs != f.RepoStatus {
				continue
			}
		}
		if f.Tag != "" {
			match := false
			for _, t := range h.Tags {
				if t == f.Tag {
					match = true
					break
				}
			}
			if !match {
				continue
			}
		}
		if f.Updates == "behind" {
			if h.AgentVersion == "" || h.AgentVersion == version.Version {
				continue
			}
		}
		out = append(out, h)
	}
	sortDashboardHosts(out, f.Sort, f.Dir)
	return out
}

// sortDashboardHosts applies the column-by-direction sort in place.
// Unknown column key falls back to name asc — defensive default that
// keeps a malformed bookmarked URL from rendering an empty table.
func sortDashboardHosts(hosts []store.Host, col, dir string) {
	less := func(i, j int) bool {
		a, b := hosts[i], hosts[j]
		switch col {
		case "os":
			if a.OS != b.OS {
				return a.OS < b.OS
			}
		case "status":
			if a.Status != b.Status {
				return a.Status < b.Status
			}
		case "repo_status":
			if a.RepoStatus != b.RepoStatus {
				return a.RepoStatus < b.RepoStatus
			}
		case "restic":
			if a.ResticVersion != b.ResticVersion {
				return a.ResticVersion < b.ResticVersion
			}
		case "snapshot_count":
			if a.SnapshotCount != b.SnapshotCount {
				return a.SnapshotCount < b.SnapshotCount
			}
		case "repo_size":
			if a.RepoSizeBytes != b.RepoSizeBytes {
				return a.RepoSizeBytes < b.RepoSizeBytes
			}
		case "last_backup":
			at, bt := time.Time{}, time.Time{}
			if a.LastBackupAt != nil {
				at = *a.LastBackupAt
			}
			if b.LastBackupAt != nil {
				bt = *b.LastBackupAt
			}
			if !at.Equal(bt) {
				return at.Before(bt)
			}
		}
		// Stable secondary key: name.
		return a.Name < b.Name
	}
	if dir == "desc" {
		sort.Slice(hosts, func(i, j int) bool { return less(j, i) })
	} else {
		sort.Slice(hosts, less)
	}
}

// buildDashboardSortURLs precomputes the link target for every
// sortable column header. Clicking the active column toggles
// direction; clicking a different column starts ascending.
func buildDashboardSortURLs(active dashboardFilter) map[string]string {
	cols := []string{"name", "os", "status", "repo_status", "restic", "snapshot_count", "repo_size", "last_backup"}
	out := make(map[string]string, len(cols))
	for _, c := range cols {
		f := active
		f.Sort = c
		if active.Sort == c && active.Dir == "asc" {
			f.Dir = "desc"
		} else {
			f.Dir = "asc"
		}
		enc := f.encode()
		if enc == "" {
			out[c] = "/"
		} else {
			out[c] = "/?" + enc
		}
	}
	return out
}

// Per-host Run-now and manual Init-repo were retired by the P2 redesign.
// Run-now lives at POST /hosts/{id}/source-groups/{gid}/run; init runs
// automatically on the agent's first WS connect after enrolment. Both
// routes return 410 Gone so any cached browser tab gets a clear error.

func (s *Server) handleUIRunBackupGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	stdhttp.Error(w,
		"per-host Run-now has moved — use POST /hosts/{id}/source-groups/{gid}/run",
		stdhttp.StatusGone)
}

func (s *Server) handleUIInitRepoGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	stdhttp.Error(w,
		"manual init-repo is gone — the server auto-inits on the agent's first connect",
		stdhttp.StatusGone)
}

// addHostPage carries the Add-host form state. The result-state
// (showing the install command + htpasswd snippet) lives at
// /hosts/pending/{token} and uses pendingHostPage instead, so the
// operator can refresh / bookmark / come back later — the password
// is decrypted from the still-alive token row on every render
// rather than living only in a one-shot rendered response.
type addHostPage struct {
	// Form fields — pre-populate the form on a re-render after a
	// validation error.
	Hostname     string
	Tags         string
	RepoURL      string
	RepoUsername string
	Paths        string
	ServerURL    string
	Error        string

	// Outstanding tokens (NS-02) — every still-valid (un-consumed,
	// un-expired) enrolment token, surfaced so an operator who closed
	// the install snippet tab can recover via Regenerate or revoke.
	OutstandingTokens []addHostOutstandingToken
}

// addHostOutstandingToken is a UI-shaped projection of a row from
// store.ListOutstandingEnrollmentTokens with the repo URL already
// decrypted-and-redacted (no creds reach the browser).
type addHostOutstandingToken struct {
	TokenHash    string // full hex hash; opaque path param for actions
	ShortHash    string // first 12 chars of TokenHash for display
	CreatedAt    time.Time
	ExpiresAt    time.Time
	RepoURL      string // redacted (no embedded creds)
	InitialPaths []string
}

// pendingHostPage is the GET /hosts/pending/{token} view. Lives
// for as long as the token does (1h ttl); once the agent enrols,
// the handler redirects to /hosts/{host_id} and this page is gone.
type pendingHostPage struct {
	Token        string
	ServerURL    string
	ExpiresAt    time.Time
	RepoURL      string
	RepoUsername string
	RepoPassword string
	InitialPaths []string
}

// handleUIAddHostGet renders the empty Add host form.
func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	view := s.baseView(r, u)
	view.Title = "Add host · restic-manager"
	view.Page = addHostPage{
		ServerURL:         s.publicURL(r),
		OutstandingTokens: s.loadOutstandingTokensForUI(r),
	}
	if err := s.deps.UI.Render(w, "add_host", view); err != nil {
		slog.Error("ui: render add_host", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// loadOutstandingTokensForUI fetches the still-valid enrolment tokens
// and decrypts each row's repo URL so the Add-host page can show a
// recoverable list. Decryption failures (rotated key etc.) are logged
// and surfaced as "(decrypt failed)" rather than crashing the page.
func (s *Server) loadOutstandingTokensForUI(r *stdhttp.Request) []addHostOutstandingToken {
	rows, err := s.deps.Store.ListOutstandingEnrollmentTokens(r.Context())
	if err != nil {
		slog.Warn("ui add_host: list outstanding tokens", "err", err)
		return nil
	}
	out := make([]addHostOutstandingToken, 0, len(rows))
	for _, row := range rows {
		short := row.TokenHash
		if len(short) > 12 {
			short = short[:12]
		}
		entry := addHostOutstandingToken{
			TokenHash:    row.TokenHash,
			ShortHash:    short,
			CreatedAt:    row.CreatedAt,
			ExpiresAt:    row.ExpiresAt,
			InitialPaths: row.InitialPaths,
		}
		if row.EncRepoCreds != "" {
			plain, derr := s.deps.AEAD.Decrypt(row.EncRepoCreds, []byte("token:"+row.TokenHash))
			if derr != nil {
				entry.RepoURL = "(decrypt failed — key rotation?)"
			} else {
				var blob repoCredsBlob
				_ = json.Unmarshal(plain, &blob)
				entry.RepoURL = restic.RedactURL(blob.RepoURL)
			}
		}
		out = append(out, entry)
	}
	return out
}

// handleUIAddHostPost validates the form, mints the enrolment token
// (with encrypted repo creds), and 303-redirects to the persistent
// pending-host page. On validation errors we re-render the form
// with the operator's typed input intact and a banner.
func (s *Server) handleUIAddHostPost(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
	}
	page := addHostPage{
		Hostname:     strings.TrimSpace(r.PostForm.Get("hostname")),
		Tags:         strings.TrimSpace(r.PostForm.Get("tags")),
		RepoURL:      strings.TrimSpace(r.PostForm.Get("repo_url")),
		RepoUsername: strings.TrimSpace(r.PostForm.Get("repo_username")),
		Paths:        r.PostForm.Get("paths"),
		ServerURL:    s.publicURL(r),
	}
	repoPassword := r.PostForm.Get("repo_password")

	if page.Hostname == "" {
		page.Error = "Hostname is required."
	} else if page.RepoURL == "" {
		page.Error = "Repo URL is required so the agent can back up the moment it comes online."
	}

	if page.Error == "" && repoPassword == "" {
		gen, err := generateRepoPassword()
		if err != nil {
			slog.Error("ui add_host: generate repo password", "err", err)
			page.Error = "Couldn’t generate a password — see the server log for details."
		} else {
			repoPassword = gen
		}
	}

	// Default repo username to the hostname when the operator left it
	// blank. With rest-server's --private-repos this is what the URL
	// path segment is expected to be anyway, and an htpasswd entry
	// always needs *some* user — defaulting saves the operator from
	// landing on a pending page with a half-formed snippet.
	repoUsername := page.RepoUsername
	if repoUsername == "" {
		repoUsername = page.Hostname
	}

	if page.Error == "" {
		token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths))
		switch {
		case err == nil:
			stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
			return
		case errors.Is(err, errMissingRepoCreds):
			page.Error = "Repo URL and password are both required."
		default:
			slog.Error("ui add_host: mint token", "err", err)
			page.Error = "Couldn’t mint a token — see the server log for details."
		}
	}

	view := s.baseView(r, u)
	view.Title = "Add host · restic-manager"
	view.Page = page
	w.WriteHeader(stdhttp.StatusUnprocessableEntity)
	if err := s.deps.UI.Render(w, "add_host", view); err != nil {
		slog.Error("ui: render add_host", "err", err)
	}
}

// handleUIPendingHost serves the durable Add-host result page —
// shown after a successful POST /hosts/new and reachable until the
// agent enrols (the page redirects to /hosts/{id} once that
// happens) or the token expires (1h ttl). The password is
// re-decrypted from the encrypted token row on every render so
// the operator can refresh, bookmark, navigate away and come back.
func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	rawToken := chi.URLParam(r, "token")
	if rawToken == "" {
		stdhttp.NotFound(w, r)
		return
	}
	tokHash := auth.HashToken(rawToken)

	status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), tokHash)
	if err != nil {
		stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
		return
	}
	if status.ConsumedHost != nil {
		stdhttp.Redirect(w, r, "/hosts/"+*status.ConsumedHost, stdhttp.StatusSeeOther)
		return
	}
	if time.Now().After(status.ExpiresAt) {
		stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
		return
	}

	att, err := s.deps.Store.GetEnrollmentTokenAttachments(r.Context(), tokHash)
	if err != nil {
		slog.Warn("ui pending: load attachments", "err", err)
		stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
		return
	}
	page := pendingHostPage{
		Token:        rawToken,
		ServerURL:    s.publicURL(r),
		ExpiresAt:    status.ExpiresAt,
		InitialPaths: att.InitialPaths,
	}
	if att.EncRepoCreds != "" {
		plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+tokHash))
		if err != nil {
			slog.Error("ui pending: decrypt creds", "err", err)
		} else {
			var blob repoCredsBlob
			if err := json.Unmarshal(plain, &blob); err == nil {
				page.RepoURL = blob.RepoURL
				page.RepoUsername = blob.RepoUsername
				page.RepoPassword = blob.RepoPassword
			}
		}
	}

	view := s.baseView(r, u)
	view.Title = "Pending host · restic-manager"
	view.Page = page
	if err := s.deps.UI.Render(w, "pending_host", view); err != nil {
		slog.Error("ui: render pending_host", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// handleUIPendingAwaiting is the polled fragment that the pending-
// host page swaps in every couple of seconds to detect "agent
// connected". Returns either the still-awaiting partial (with the
// HTMX poll trigger preserved) or the connected partial (no poll —
// includes a meta-refresh to /hosts/{id} so the operator lands on
// the host detail).
func (s *Server) handleUIPendingAwaiting(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	if u := s.requireUIUser(w, r); u == nil {
		return
	}
	rawToken := chi.URLParam(r, "token")
	if rawToken == "" {
		stdhttp.Error(w, "missing token", stdhttp.StatusBadRequest)
		return
	}
	status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), auth.HashToken(rawToken))
	page := awaitingFragment{Token: rawToken, ExpiresAt: status.ExpiresAt}
	switch {
	case errors.Is(err, store.ErrNotFound):
		page.State = "expired"
	case err != nil:
		slog.Warn("ui awaiting: lookup", "err", err)
		page.State = "expired"
	case status.ConsumedHost != nil:
		page.State = "connected"
		page.HostID = *status.ConsumedHost
		if h, err := s.deps.Store.GetHost(r.Context(), *status.ConsumedHost); err == nil {
			page.HostName = h.Name
			page.LastSeenAt = h.LastSeenAt
		}
	case time.Now().After(status.ExpiresAt):
		page.State = "expired"
	default:
		page.State = "awaiting"
	}
	if err := s.deps.UI.RenderPartial(w, "awaiting_agent", ui.ViewData{Page: page}); err != nil {
		slog.Error("ui: render awaiting_agent", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// awaitingFragment carries the state for the polled awaiting-agent
// partial. State == awaiting | connected | expired drives both the
// copy block and whether HTMX keeps polling.
type awaitingFragment struct {
	State      string
	Token      string
	ExpiresAt  time.Time
	HostID     string
	HostName   string
	LastSeenAt *time.Time
}

// hostChromeData is the field set the host_chrome partial reads from
// every host-detail-tab page's Page struct. Embed it as the first
// (anonymous) field of the page struct so .Page.Host / .Page.SubTab
// resolve via field promotion in the template.
type hostChromeData struct {
	Host             store.Host
	SubTab           string // snapshots | sources | schedules | repo
	Crumb            string // breadcrumb tail ("snapshots" / "sources" / etc)
	SourceGroupCount int
	ScheduleCount    int
	ScheduleVersion  int64 // host_schedule_version (latest desired)
	// UpdateAvailable + TargetVersion drive the agent-out-of-date chip
	// in the host detail header. UpdateAvailable is true iff the host
	// has connected at least once AND its agent_version != server's.
	UpdateAvailable bool
	TargetVersion   string
	// Online + UpdateInProgress drive the per-host "Update agent"
	// button on host_detail. Online mirrors hub.Connected; pulled here
	// so the button can disable when the host is unreachable.
	Online           bool
	UpdateInProgress bool
	// CanAdmin is true when the viewing user has admin role; used to
	// gate the "Update agent" button. Kept on the chrome struct so any
	// page reusing host_chrome already has it for free.
	CanAdmin bool
	// KnownTags is the union of tags already in use across the fleet,
	// used for autocomplete on the host-tags edit form. Cheap query.
	KnownTags []string

	// Auto-init status surfaced from the latest 'init' job.
	// InitStatus is "succeeded" | "failed" | "running" | "queued" | "" (never run).
	InitStatus string
	InitAt     *time.Time // started_at if non-nil else created_at
	InitJobID  string

	// Latest 'restore' job — surfaced as a small line below the
	// init-status one so the operator has at-a-glance visibility into
	// recent destructive activity. Empty status means no restore has
	// ever run on this host.
	RestoreStatus string
	RestoreAt     *time.Time
	RestoreJobID  string
}

// loadHostChrome fetches the per-tab counts that every host-detail tab
// renders in the chrome (sub-tab badges + version indicator). On any
// non-fatal store error it logs and degrades to zeros — better to
// render the page with stale counts than 500 the whole tab.
func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData {
	d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb}
	d.TargetVersion = version.Version
	d.UpdateAvailable = host.AgentVersion != "" && host.AgentVersion != version.Version
	if s.deps.Hub != nil {
		d.Online = s.deps.Hub.Connected(host.ID)
	}
	if existing, _ := s.deps.Store.RunningUpdateJobForHost(r.Context(), host.ID); existing != "" {
		d.UpdateInProgress = true
	}
	if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil {
		d.SourceGroupCount = len(groups)
	} else {
		slog.Warn("ui chrome: list source groups", "host_id", host.ID, "err", err)
	}
	if scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID); err == nil {
		d.ScheduleCount = len(scheds)
	} else {
		slog.Warn("ui chrome: list schedules", "host_id", host.ID, "err", err)
	}
	if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil {
		d.ScheduleVersion = v
	}
	if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "init"); err == nil && j != nil {
		d.InitStatus = j.Status
		d.InitJobID = j.ID
		t := j.CreatedAt
		if j.StartedAt != nil {
			t = *j.StartedAt
		}
		d.InitAt = &t
	}
	if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "restore"); err == nil && j != nil {
		d.RestoreStatus = j.Status
		d.RestoreJobID = j.ID
		t := j.CreatedAt
		if j.StartedAt != nil {
			t = *j.StartedAt
		}
		d.RestoreAt = &t
	}
	if tags, err := s.deps.Store.DistinctHostTags(r.Context()); err == nil {
		d.KnownTags = tags
	}
	return d
}

// handleUIHostTagsSave accepts a comma-separated tag list, normalises,
// dedups, and writes. Operator-band; mounted in server.go.
func (s *Server) handleUIHostTagsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	hostID := chi.URLParam(r, "id")
	if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
		stdhttp.NotFound(w, r)
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	raw := r.PostForm.Get("tags")
	tags := normaliseTags(raw)
	if err := s.deps.Store.SetHostTags(r.Context(), hostID, tags); err != nil {
		slog.Error("ui host tags: save", "host_id", hostID, "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:     "host.tags_updated",
		TargetKind: ptr("host"), TargetID: &hostID,
		TS: time.Now().UTC(),
	})
	stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther)
}

// handleUIHostModeSave flips a host's always-on flag. Checkbox present
// in the form (value any) => always-on; absent => intermittent.
// Operator-band; mounted in server.go. On change we clear open
// offline/staleness alerts via the engine so the next sweep re-raises
// only what still applies under the new mode.
func (s *Server) handleUIHostModeSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	hostID := chi.URLParam(r, "id")
	if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
		stdhttp.NotFound(w, r)
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	alwaysOn := r.PostForm.Get("always_on") != ""
	if err := s.deps.Store.SetHostAlwaysOn(r.Context(), hostID, alwaysOn); err != nil {
		slog.Error("ui host mode: save", "host_id", hostID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if s.deps.AlertEngine != nil {
		s.deps.AlertEngine.ResolveOnModeChange(r.Context(), hostID, time.Now().UTC())
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
		Action:     "host.mode_updated",
		TargetKind: ptr("host"), TargetID: &hostID,
		TS: time.Now().UTC(),
	})
	stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther)
}

// normaliseTags splits a comma-separated string, lowercases each token,
// trims whitespace, drops empties, and dedupes. Order is preserved
// from first occurrence (so the user's typing order shows on screen).
func normaliseTags(raw string) []string {
	parts := strings.Split(raw, ",")
	seen := make(map[string]bool, len(parts))
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		t := strings.ToLower(strings.TrimSpace(p))
		if t == "" || seen[t] {
			continue
		}
		seen[t] = true
		out = append(out, t)
	}
	return out
}

// hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct {
	hostChromeData
	Snapshots []store.Snapshot
	// SnapshotsShown is the number rendered (we cap at ~50 for the
	// first slice; pagination lands when it matters).
	SnapshotsShown int
	// LegacyRestic is true when the host's restic version predates
	// 0.17, in which case `restic snapshots --json` doesn't embed the
	// per-snapshot summary block and the Size/Files columns render
	// blank. The template uses this to attach a tooltip to those
	// column headers explaining the version requirement.
	LegacyRestic bool
}

// handleUIHostDetail is the host detail page (snapshots tab by default).
// Auth-gated. 404 if the host id is unknown.
func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	hostID := chi.URLParam(r, "id")
	if hostID == "" {
		stdhttp.NotFound(w, r)
		return
	}
	host, err := s.deps.Store.GetHost(r.Context(), hostID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui host detail: get host", "host_id", hostID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
	if err != nil {
		slog.Error("ui host detail: list snapshots", "host_id", hostID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	const cap = 50
	shown := snaps
	if len(shown) > cap {
		shown = shown[:cap]
	}

	view := s.baseView(r, u)
	view.Title = host.Name + " · restic-manager"
	chrome := s.loadHostChrome(r, *host, "snapshots", "snapshots")
	chrome.CanAdmin = u.Role == string(store.RoleAdmin)
	view.Page = hostDetailPage{
		hostChromeData: chrome,
		Snapshots:      shown,
		SnapshotsShown: len(shown),
		LegacyRestic:   !restic.Env{Version: host.ResticVersion}.AtLeastVersion(0, 17),
	}
	if err := s.deps.UI.Render(w, "host_detail", view); err != nil {
		slog.Error("ui: render host_detail", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// generateRepoPassword returns a 24-byte URL-safe random string for
// use as a per-host rest-server password. URL-safe alphabet keeps
// it shell-safe inside single quotes — important since the operator
// pastes it into an `htpasswd -i` invocation on the rest-server.
func generateRepoPassword() (string, error) {
	var buf [24]byte
	if _, err := rand.Read(buf[:]); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(buf[:]), nil
}

// splitPaths parses the textarea content into a clean []string —
// one path per line, leading/trailing whitespace trimmed, blanks
// dropped.
func splitPaths(s string) []string {
	out := []string{}
	for _, line := range strings.Split(s, "\n") {
		if p := strings.TrimSpace(line); p != "" {
			out = append(out, p)
		}
	}
	return out
}

// publicURL is what the operator should paste into the install
// command. Prefers RM_BASE_URL (set by the operator's reverse
// proxy config) and falls back to scheme + Host of the inbound
// request — useful for local smoke without a proxy.
func (s *Server) publicURL(r *stdhttp.Request) string {
	if s.deps.Cfg.BaseURL != "" {
		return strings.TrimRight(s.deps.Cfg.BaseURL, "/")
	}
	scheme := "http"
	if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
		scheme = "https"
	}
	return scheme + "://" + r.Host
}

// jobDetailPage carries everything the live-log template renders.
type jobDetailPage struct {
	Job      store.Job
	Host     store.Host
	Logs     []store.JobLogLine
	NextSeq  int64
	IsActive bool // true while status is queued|running
}

// handleUIJobDetail renders the live job log view (snapshot of any
// already-persisted log lines + an empty stream container the JS
// fills via the WS).
func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	jobID := chi.URLParam(r, "id")
	if jobID == "" {
		stdhttp.NotFound(w, r)
		return
	}
	job, err := s.deps.Store.GetJob(r.Context(), jobID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	host, err := s.deps.Store.GetHost(r.Context(), job.HostID)
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	logs, err := s.deps.Store.ListJobLogs(r.Context(), jobID, 0, 0)
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	var nextSeq int64
	if n := len(logs); n > 0 {
		nextSeq = logs[n-1].Seq
	}

	view := s.baseView(r, u)
	view.Title = job.Kind + " · " + host.Name + " · restic-manager"
	view.Page = jobDetailPage{
		Job:      *job,
		Host:     *host,
		Logs:     logs,
		NextSeq:  nextSeq,
		IsActive: job.Status == "queued" || job.Status == "running",
	}
	if err := s.deps.UI.Render(w, "job_detail", view); err != nil {
		slog.Error("ui: render job_detail", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// handleJobStream is the browser-side WS endpoint. Auth is via the
// session cookie (the HTTP layer does the lookup before upgrading).
// On connect we subscribe to JobHub for the given job_id; the
// subscriber goroutine pumps fan-out messages to the client until
// the job finishes or the browser navigates away.
//
// Messages on the wire are the same api.Envelope shape as on the
// agent side, so the client-side JS can switch on env.type the
// same way our Go code does.
func (s *Server) handleJobStream(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	if u, _ := s.sessionUser(r); u == nil {
		stdhttp.Error(w, "unauthorised", stdhttp.StatusUnauthorized)
		return
	}
	jobID := chi.URLParam(r, "id")
	if jobID == "" {
		stdhttp.Error(w, "missing job id", stdhttp.StatusBadRequest)
		return
	}
	if _, err := s.deps.Store.GetJob(r.Context(), jobID); err != nil {
		stdhttp.NotFound(w, r)
		return
	}

	conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
		InsecureSkipVerify: true, // Origin checks pointless for a same-origin browser hop.
	})
	if err != nil {
		slog.Warn("ws browser accept failed", "job_id", jobID, "err", err)
		return
	}
	defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()

	// Wrap so we get the same Send semantics as the agent path.
	c := ws.NewConn("browser-"+jobID, conn)

	// Register first so future broadcasts reach us, then re-fetch the
	// job to close the late-subscriber race: a fast-failing job can
	// finish (DB write + Broadcast) before the browser's WS hop
	// completes, leaving the JS waiting forever for a job.finished
	// that already passed. If the job is already terminal here, prime
	// the subscriber with a synthetic job.finished so the JS reloads.
	sub := s.deps.JobHub.Register(jobID)
	if cur, gerr := s.deps.Store.GetJob(r.Context(), jobID); gerr == nil && isTerminalJobStatus(cur.Status) {
		if env, ferr := buildSyntheticJobFinished(cur); ferr == nil {
			sub.Send(env)
		}
	}
	sub.Run(r.Context(), c)
}

func isTerminalJobStatus(s string) bool {
	switch api.JobStatus(s) {
	case api.JobSucceeded, api.JobFailed, api.JobCancelled:
		return true
	}
	return false
}

func buildSyntheticJobFinished(job *store.Job) (api.Envelope, error) {
	var fin time.Time
	if job.FinishedAt != nil {
		fin = *job.FinishedAt
	}
	exit := 0
	if job.ExitCode != nil {
		exit = *job.ExitCode
	}
	errMsg := ""
	if job.Error != nil {
		errMsg = *job.Error
	}
	return api.Marshal(api.MsgJobFinished, "", api.JobFinishedPayload{
		JobID:      job.ID,
		Status:     api.JobStatus(job.Status),
		ExitCode:   exit,
		FinishedAt: fin,
		Stats:      job.Stats,
		Error:      errMsg,
	})
}

// handleUILoginGet renders the login form. If the user is already
// signed in we redirect them home — login is for the unauthenticated.
func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	if u, _ := s.sessionUser(r); u != nil {
		stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
		return
	}
	// First-run: no users + token still in memory ⇒ funnel the visitor
	// to the bootstrap page so they don't have to know the API exists.
	if s.bootstrapAvailable(r) {
		stdhttp.Redirect(w, r, "/bootstrap", stdhttp.StatusSeeOther)
		return
	}
	view := ui.ViewData{
		Version:   s.version(),
		OIDCError: r.URL.Query().Get("oidc_error"),
	}
	if s.deps.OIDC != nil {
		view.OIDCEnabled = true
		view.OIDCDisplayName = s.deps.OIDC.DisplayName()
	}
	if err := s.deps.UI.Render(w, "login", view); err != nil {
		slog.Error("ui: render login", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// handleUILoginPost consumes the form, validates, mints a session,
// and either redirects to / on success or re-renders the form with
// an error banner on failure.
func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	username := r.PostForm.Get("username")
	password := r.PostForm.Get("password")

	if _, err := s.authenticateAndSession(w, r, username, password); err != nil {
		// Re-render the form. Single generic message — see
		// authenticateAndSession's note on not leaking user existence.
		view := ui.ViewData{
			Version:  s.version(),
			Username: username,
			Error:    "Invalid username or password.",
		}
		if s.deps.OIDC != nil {
			view.OIDCEnabled = true
			view.OIDCDisplayName = s.deps.OIDC.DisplayName()
		}
		w.WriteHeader(stdhttp.StatusUnauthorized)
		if err := s.deps.UI.Render(w, "login", view); err != nil {
			slog.Error("ui: render login (post-fail)", "err", err)
		}
		return
	}
	stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
}

// handleUILogoutPost is the form-submit twin of /api/auth/logout. For
// local sessions it drops the cookie and redirects to /login. For OIDC
// sessions, if the IdP advertised an end_session_endpoint it performs
// RP-initiated logout by redirecting there with id_token_hint and
// post_logout_redirect_uri.
func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	c, err := r.Cookie(sessionCookieName)
	if err != nil {
		stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
		return
	}
	hash := auth.HashToken(c.Value)
	sess, _ := s.deps.Store.LookupSession(r.Context(), hash)
	_ = s.deps.Store.DeleteSession(r.Context(), hash)

	// Default: drop session, go to /login.
	dest := "/login"

	// OIDC session with a discovered end_session_endpoint? Compose
	// the IdP logout URL with id_token_hint + post_logout_redirect_uri.
	if sess != nil && sess.IDToken != "" && s.deps.OIDC != nil &&
		s.deps.OIDC.EndSessionEndpoint() != "" {
		v := url.Values{}
		v.Set("id_token_hint", sess.IDToken)
		if base := strings.TrimRight(s.deps.Cfg.BaseURL, "/"); base != "" {
			v.Set("post_logout_redirect_uri", base+"/login")
		}
		dest = s.deps.OIDC.EndSessionEndpoint() + "?" + v.Encode()
	}

	// Clear the cookie.
	stdhttp.SetCookie(w, &stdhttp.Cookie{
		Name:     sessionCookieName,
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
		Secure:   s.deps.Cfg.CookieSecure,
		SameSite: stdhttp.SameSiteLaxMode,
	})
	stdhttp.Redirect(w, r, dest, stdhttp.StatusSeeOther)
}
