// Package ui provides the HTMX-based management web interface for MCIAS.
//
// Security design:
//   - Session tokens are stored as HttpOnly, Secure, SameSite=Strict cookies.
//     They are never accessible to JavaScript.
//   - CSRF protection uses the HMAC-signed Double-Submit Cookie pattern.
//     The mcias_csrf cookie is non-HttpOnly so HTMX can include it in the
//     X-CSRF-Token header on every mutating request. SameSite=Strict is the
//     primary browser-level CSRF defence.
//   - UI handlers call internal Go functions directly — no internal HTTP round-trips.
//   - All templates are parsed once at startup via embed.FS; no dynamic template
//     loading from disk at request time.
package ui

import (
	"bytes"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"html/template"
	"io/fs"
	"log/slog"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"

	"git.wntrmute.dev/mc/mcias/internal/auth"
	"git.wntrmute.dev/mc/mcias/internal/config"
	"git.wntrmute.dev/mc/mcias/internal/db"
	"git.wntrmute.dev/mc/mcias/internal/middleware"
	"git.wntrmute.dev/mc/mcias/internal/model"
	"git.wntrmute.dev/mc/mcias/internal/vault"
	"git.wntrmute.dev/mc/mcias/web"
)

const (
	sessionCookieName = "mcias_session"
	csrfCookieName    = "mcias_csrf"
	totpNonceTTL      = 90 * time.Second // single-use TOTP step nonce lifetime
	totpNonceBytes    = 16               // 128 bits of entropy
)

// pendingLogin is a short-lived record created after password verification
// succeeds but before TOTP confirmation. It holds the account ID so the
// TOTP step never needs to re-transmit the password.
//
// Security: the nonce is single-use (deleted on first lookup) and expires
// after totpNonceTTL to bound the window of a stolen nonce.
type pendingLogin struct {
	expiresAt time.Time
	accountID int64
}

// tokenDownload is a short-lived record that holds a just-issued service token
// string so the operator can download it as a file.  It is single-use and
// expires after tokenDownloadTTL.
//
// Security: the token string is stored only for tokenDownloadTTL after
// issuance.  The nonce is random (128 bits) and single-use: it is deleted from
// the map on first retrieval so it cannot be replayed.
type tokenDownload struct {
	expiresAt time.Time
	token     string
	accountID string // service account UUID (for the filename)
}

const tokenDownloadTTL = 5 * time.Minute

// UIServer serves the HTMX-based management UI.
type UIServer struct {
	tmpls              map[string]*template.Template // page name → template set
	db                 *db.DB
	cfg                *config.Config
	logger             *slog.Logger
	csrf               *CSRFManager
	vault              *vault.Vault
	pendingLogins      sync.Map // nonce (string) → *pendingLogin
	tokenDownloads     sync.Map // nonce (string) → *tokenDownload
	pendingTOTPEnrolls sync.Map // nonce (string) → *pendingTOTPEnroll
}

// issueTOTPNonce creates a random single-use nonce for the TOTP step and
// stores the account ID it corresponds to. Returns the hex-encoded nonce.
func (u *UIServer) issueTOTPNonce(accountID int64) (string, error) {
	raw := make([]byte, totpNonceBytes)
	if _, err := rand.Read(raw); err != nil {
		return "", fmt.Errorf("ui: generate TOTP nonce: %w", err)
	}
	nonce := hex.EncodeToString(raw)
	u.pendingLogins.Store(nonce, &pendingLogin{
		accountID: accountID,
		expiresAt: time.Now().Add(totpNonceTTL),
	})
	return nonce, nil
}

// consumeTOTPNonce looks up and deletes the nonce, returning the associated
// account ID. Returns (0, false) if the nonce is unknown or expired.
func (u *UIServer) consumeTOTPNonce(nonce string) (int64, bool) {
	v, ok := u.pendingLogins.LoadAndDelete(nonce)
	if !ok {
		return 0, false
	}
	pl, ok2 := v.(*pendingLogin)
	if !ok2 {
		return 0, false
	}
	if time.Now().After(pl.expiresAt) {
		return 0, false
	}
	return pl.accountID, true
}

// pendingTOTPEnroll stores the account ID for a TOTP enrollment ceremony
// that has passed password re-auth and generated a secret, awaiting code
// confirmation.
type pendingTOTPEnroll struct {
	expiresAt time.Time
	accountID int64
}

const totpEnrollTTL = 5 * time.Minute

// issueTOTPEnrollNonce creates a random single-use nonce for the TOTP
// enrollment confirmation step.
func (u *UIServer) issueTOTPEnrollNonce(accountID int64) (string, error) {
	raw := make([]byte, totpNonceBytes)
	if _, err := rand.Read(raw); err != nil {
		return "", fmt.Errorf("ui: generate TOTP enroll nonce: %w", err)
	}
	nonce := hex.EncodeToString(raw)
	u.pendingTOTPEnrolls.Store(nonce, &pendingTOTPEnroll{
		accountID: accountID,
		expiresAt: time.Now().Add(totpEnrollTTL),
	})
	return nonce, nil
}

// consumeTOTPEnrollNonce looks up and deletes the enrollment nonce,
// returning the associated account ID. Returns (0, false) if unknown or expired.
func (u *UIServer) consumeTOTPEnrollNonce(nonce string) (int64, bool) {
	v, ok := u.pendingTOTPEnrolls.LoadAndDelete(nonce)
	if !ok {
		return 0, false
	}
	pe, ok2 := v.(*pendingTOTPEnroll)
	if !ok2 {
		return 0, false
	}
	if time.Now().After(pe.expiresAt) {
		return 0, false
	}
	return pe.accountID, true
}

// dummyHash returns the pre-computed Argon2id PHC hash for constant-time dummy
// verification when an account is unknown or inactive (F-07).
// Delegates to auth.DummyHash() which uses sync.Once for one-time computation.
func (u *UIServer) dummyHash() string {
	return auth.DummyHash()
}

// New constructs a UIServer, parses all templates, and returns it.
// Returns an error if template parsing fails.
//
// The CSRFManager is created lazily from vault key material when the vault
// is unsealed. When sealed, CSRF operations fail, but the sealed middleware
// prevents reaching CSRF-protected routes (chicken-and-egg resolution).
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) (*UIServer, error) {
	csrf := newCSRFManagerFromVault(v)

	funcMap := template.FuncMap{
		"formatTime": func(t time.Time) string {
			if t.IsZero() {
				return ""
			}
			return t.UTC().Format("2006-01-02 15:04:05")
		},
		"truncateJTI": func(jti string) string {
			if len(jti) > 8 {
				return jti[:8]
			}
			return jti
		},
		"string": func(v interface{}) string {
			switch s := v.(type) {
			case model.AccountStatus:
				return string(s)
			case model.AccountType:
				return string(s)
			default:
				return fmt.Sprintf("%v", v)
			}
		},
		"hasRole": func(roles []string, role string) bool {
			for _, r := range roles {
				if r == role {
					return true
				}
			}
			return false
		},
		"not": func(b bool) bool { return !b },
		// derefInt64 safely dereferences a *int64, returning 0 for nil.
		// Used in templates to compare owner IDs without triggering nil panics.
		"derefInt64": func(p *int64) int64 {
			if p == nil {
				return 0
			}
			return *p
		},
		// isPGCredOwner returns true when actorID and cred are both non-nil
		// and actorID matches cred.OwnerID.  Safe to call with nil arguments.
		"isPGCredOwner": func(actorID *int64, cred *model.PGCredential) bool {
			if actorID == nil || cred == nil || cred.OwnerID == nil {
				return false
			}
			return *actorID == *cred.OwnerID
		},
		// derefTime dereferences a *time.Time, returning the zero time for nil.
		"derefTime": func(p *time.Time) time.Time {
			if p == nil {
				return time.Time{}
			}
			return *p
		},
		"add": func(a, b int) int { return a + b },
		"sub": func(a, b int) int { return a - b },
		"gt":  func(a, b int) bool { return a > b },
		"lt":  func(a, b int) bool { return a < b },
		"prettyJSON": func(s string) string {
			var v json.RawMessage
			if json.Unmarshal([]byte(s), &v) != nil {
				return s
			}
			pretty, err := json.MarshalIndent(v, "", "  ")
			if err != nil {
				return s
			}
			return string(pretty)
		},
	}

	// Parse shared templates (base layout + all fragments) into a base set.
	// Each page template is then parsed into a clone of this base set so that
	// competing "content"/"title" definitions do not collide.
	sharedFiles := []string{
		"templates/base.html",
		"templates/fragments/account_row.html",
		"templates/fragments/account_status.html",
		"templates/fragments/roles_editor.html",
		"templates/fragments/token_list.html",
		"templates/fragments/totp_step.html",
		"templates/fragments/error.html",
		"templates/fragments/audit_rows.html",
		"templates/fragments/pgcreds_form.html",
		"templates/fragments/tags_editor.html",
		"templates/fragments/policy_row.html",
		"templates/fragments/policy_form.html",
		"templates/fragments/password_reset_form.html",
		"templates/fragments/password_change_form.html",
		"templates/fragments/token_delegates.html",
		"templates/fragments/webauthn_credentials.html",
		"templates/fragments/webauthn_enroll.html",
		"templates/fragments/totp_section.html",
		"templates/fragments/totp_enroll_qr.html",
		"templates/fragments/sso_client_row.html",
	}
	base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
	if err != nil {
		return nil, fmt.Errorf("ui: parse shared templates: %w", err)
	}

	// Each page template defines "content" and "title" blocks; parsing them
	// into separate clones prevents the last-defined block from winning.
	pageFiles := map[string]string{
		"login":            "templates/login.html",
		"dashboard":        "templates/dashboard.html",
		"accounts":         "templates/accounts.html",
		"account_detail":   "templates/account_detail.html",
		"audit":            "templates/audit.html",
		"audit_detail":     "templates/audit_detail.html",
		"policies":         "templates/policies.html",
		"pgcreds":          "templates/pgcreds.html",
		"profile":          "templates/profile.html",
		"unseal":           "templates/unseal.html",
		"service_accounts": "templates/service_accounts.html",
		"sso_clients":      "templates/sso_clients.html",
	}
	tmpls := make(map[string]*template.Template, len(pageFiles))
	for name, file := range pageFiles {
		clone, cloneErr := base.Clone()
		if cloneErr != nil {
			return nil, fmt.Errorf("ui: clone base templates for %s: %w", name, cloneErr)
		}
		if _, parseErr := clone.ParseFS(web.TemplateFS, file); parseErr != nil {
			return nil, fmt.Errorf("ui: parse page template %s: %w", name, parseErr)
		}
		tmpls[name] = clone
	}

	srv := &UIServer{
		db:     database,
		cfg:    cfg,
		vault:  v,
		logger: logger,
		csrf:   csrf,
		tmpls:  tmpls,
	}

	// Security (DEF-02): launch a background goroutine to evict expired TOTP
	// nonces from pendingLogins.  consumeTOTPNonce deletes entries on use, but
	// entries abandoned by users who never complete step 2 would otherwise
	// accumulate indefinitely, enabling a memory-exhaustion attack.
	go srv.cleanupPendingLogins()
	go srv.cleanupTokenDownloads()
	go srv.cleanupPendingTOTPEnrolls()

	return srv, nil
}

// cleanupPendingLogins periodically evicts expired entries from pendingLogins.
// It runs every 5 minutes, which is well within the 90-second nonce TTL, so
// stale entries are removed before they can accumulate to any significant size.
func (u *UIServer) cleanupPendingLogins() {
	ticker := time.NewTicker(5 * time.Minute)
	defer ticker.Stop()
	for range ticker.C {
		now := time.Now()
		u.pendingLogins.Range(func(key, value any) bool {
			pl, ok := value.(*pendingLogin)
			if !ok || now.After(pl.expiresAt) {
				u.pendingLogins.Delete(key)
			}
			return true
		})
	}
}

// storeTokenDownload saves a just-issued token string in the short-lived
// download store and returns a random single-use nonce the caller can include
// in the response.  The download nonce expires after tokenDownloadTTL.
func (u *UIServer) storeTokenDownload(tokenStr, accountID string) (string, error) {
	raw := make([]byte, 16)
	if _, err := rand.Read(raw); err != nil {
		return "", fmt.Errorf("ui: generate download nonce: %w", err)
	}
	nonce := hex.EncodeToString(raw)
	u.tokenDownloads.Store(nonce, &tokenDownload{
		token:     tokenStr,
		accountID: accountID,
		expiresAt: time.Now().Add(tokenDownloadTTL),
	})
	return nonce, nil
}

// consumeTokenDownload looks up, validates, and deletes the download nonce.
// Returns the token string and account UUID, or ("", "", false) if the nonce
// is unknown or expired.
//
// Security: single-use deletion prevents replay; expiry bounds the window.
func (u *UIServer) consumeTokenDownload(nonce string) (tokenStr, accountID string, ok bool) {
	v, loaded := u.tokenDownloads.LoadAndDelete(nonce)
	if !loaded {
		return "", "", false
	}
	td, valid := v.(*tokenDownload)
	if !valid || time.Now().After(td.expiresAt) {
		return "", "", false
	}
	return td.token, td.accountID, true
}

// cleanupTokenDownloads periodically evicts expired entries from tokenDownloads.
func (u *UIServer) cleanupTokenDownloads() {
	ticker := time.NewTicker(5 * time.Minute)
	defer ticker.Stop()
	for range ticker.C {
		now := time.Now()
		u.tokenDownloads.Range(func(key, value any) bool {
			td, ok := value.(*tokenDownload)
			if !ok || now.After(td.expiresAt) {
				u.tokenDownloads.Delete(key)
			}
			return true
		})
	}
}

// cleanupPendingTOTPEnrolls periodically evicts expired TOTP enrollment nonces.
func (u *UIServer) cleanupPendingTOTPEnrolls() {
	ticker := time.NewTicker(5 * time.Minute)
	defer ticker.Stop()
	for range ticker.C {
		now := time.Now()
		u.pendingTOTPEnrolls.Range(func(key, value any) bool {
			pe, ok := value.(*pendingTOTPEnroll)
			if !ok || now.After(pe.expiresAt) {
				u.pendingTOTPEnrolls.Delete(key)
			}
			return true
		})
	}
}

// Register attaches all UI routes to mux, wrapped with security headers.
// All UI responses (pages, fragments, redirects, static assets) carry the
// headers added by securityHeaders.
func (u *UIServer) Register(mux *http.ServeMux) {
	// Build a dedicated child mux for all UI routes so that securityHeaders
	// can be applied once as a wrapping middleware rather than per-route.
	uiMux := http.NewServeMux()

	// Static assets — serve from the web/static/ sub-directory of the embed.
	staticSubFS, err := fs.Sub(web.StaticFS, "static")
	if err != nil {
		panic(fmt.Sprintf("ui: static sub-FS: %v", err))
	}
	// Security (SEC-07): wrap the file server to suppress directory listings.
	// Without this, GET /static/ returns an index of all static assets,
	// revealing framework details to an attacker.
	uiMux.Handle("GET /static/", http.StripPrefix("/static/", noDirListing(http.FileServerFS(staticSubFS))))

	// Redirect root to login.
	uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/" {
			http.Redirect(w, r, "/login", http.StatusFound)
			return
		}
		http.NotFound(w, r)
	})

	// Security (DEF-01, DEF-03): apply the same per-IP rate limit as the REST
	// /v1/auth/login endpoint, using the same proxy-aware IP extraction so
	// the rate limit is applied to real client IPs behind a reverse proxy.
	var trustedProxy net.IP
	if u.cfg.Server.TrustedProxy != "" {
		trustedProxy = net.ParseIP(u.cfg.Server.TrustedProxy)
	}
	loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)

	// Vault unseal routes (no session required, no CSRF — vault is sealed).
	unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
	uiMux.HandleFunc("GET /unseal", u.handleUnsealPage)
	uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost)))

	// SSO authorize route (no session required, rate-limited).
	uiMux.Handle("GET /sso/authorize", loginRateLimit(http.HandlerFunc(u.handleSSOAuthorize)))

	// Auth routes (no session required).
	uiMux.HandleFunc("GET /login", u.handleLoginPage)
	uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
	uiMux.HandleFunc("POST /logout", u.handleLogout)
	// WebAuthn login routes (public, rate-limited).
	uiMux.Handle("POST /login/webauthn/begin", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginBegin)))
	uiMux.Handle("POST /login/webauthn/finish", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginFinish)))

	// Protected routes.
	//
	// Security: three distinct access levels:
	//   - authed:    any valid session cookie (authenticated user)
	//   - admin:     authed + admin role in JWT claims (mutating admin ops)
	//   - adminGet:  authed + admin role (read-only admin pages, no CSRF)
	authed := u.requireCookieAuth
	admin := func(h http.HandlerFunc) http.Handler {
		return authed(u.requireAdminRole(u.requireCSRF(http.HandlerFunc(h))))
	}
	adminGet := func(h http.HandlerFunc) http.Handler {
		return authed(u.requireAdminRole(http.HandlerFunc(h)))
	}

	uiMux.Handle("GET /dashboard", authed(http.HandlerFunc(u.handleDashboard)))
	uiMux.Handle("GET /accounts", adminGet(u.handleAccountsList))
	uiMux.Handle("POST /accounts", admin(u.handleCreateAccount))
	uiMux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
	uiMux.Handle("PATCH /accounts/{id}/status", admin(u.handleUpdateAccountStatus))
	uiMux.Handle("DELETE /accounts/{id}", admin(u.handleDeleteAccount))
	uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
	uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
	uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
	// Token issuance is accessible to both admins and delegates; the handler
	// enforces the admin-or-delegate check internally.
	uiMux.Handle("POST /accounts/{id}/token", authed(u.requireCSRF(http.HandlerFunc(u.handleIssueSystemToken))))
	// Token download uses a one-time nonce issued at token-issuance time.
	uiMux.Handle("GET /token/download/{nonce}", authed(http.HandlerFunc(u.handleDownloadToken)))
	// Token issue delegate management — admin only.
	uiMux.Handle("POST /accounts/{id}/token/delegates", admin(u.handleGrantTokenDelegate))
	uiMux.Handle("DELETE /accounts/{id}/token/delegates/{grantee}", admin(u.handleRevokeTokenDelegate))
	uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
	uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
	uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
	uiMux.Handle("GET /pgcreds", adminGet(u.handlePGCredsList))
	uiMux.Handle("POST /pgcreds", admin(u.handleCreatePGCreds))
	uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
	uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
	uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
	uiMux.Handle("GET /policies", adminGet(u.handlePoliciesPage))
	uiMux.Handle("POST /policies", admin(u.handleCreatePolicyRule))
	uiMux.Handle("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
	uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
	uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
	uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
	uiMux.Handle("GET /sso-clients", adminGet(u.handleSSOClientsPage))
	uiMux.Handle("POST /sso-clients", admin(u.handleCreateSSOClientUI))
	uiMux.Handle("PATCH /sso-clients/{clientId}/toggle", admin(u.handleToggleSSOClient))
	uiMux.Handle("DELETE /sso-clients/{clientId}", admin(u.handleDeleteSSOClientUI))

	// Service accounts page — accessible to any authenticated user; shows only
	// the service accounts for which the current user is a token-issue delegate.
	uiMux.Handle("GET /service-accounts", authed(http.HandlerFunc(u.handleServiceAccountsPage)))

	// Profile routes — accessible to any authenticated user (not admin-only).
	uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
	uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
	// WebAuthn profile routes (enrollment and management).
	uiMux.Handle("POST /profile/webauthn/begin", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnBegin))))
	uiMux.Handle("POST /profile/webauthn/finish", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnFinish))))
	uiMux.Handle("DELETE /profile/webauthn/{id}", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnDelete))))
	// TOTP profile routes (enrollment).
	uiMux.Handle("POST /profile/totp/enroll", authed(u.requireCSRF(http.HandlerFunc(u.handleTOTPEnrollStart))))
	uiMux.Handle("POST /profile/totp/confirm", authed(u.requireCSRF(http.HandlerFunc(u.handleTOTPConfirm))))
	// Admin WebAuthn management.
	uiMux.Handle("DELETE /accounts/{id}/webauthn/{credentialId}", admin(u.handleAdminWebAuthnDelete))
	// Admin TOTP removal.
	uiMux.Handle("DELETE /accounts/{id}/totp", admin(u.handleAdminTOTPRemove))

	// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
	// catch-all for all UI paths; the more-specific /v1/ API patterns registered
	// on the parent mux continue to take precedence per Go's routing rules.
	mux.Handle("/", securityHeaders(uiMux))
}

// ---- Middleware ----

// requireCookieAuth validates the mcias_session cookie and injects claims.
// On failure, HTMX requests get HX-Redirect; browser requests get a redirect.
func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cookie, err := r.Cookie(sessionCookieName)
		if err != nil || cookie.Value == "" {
			u.redirectToLogin(w, r)
			return
		}

		pubKey, err := u.vault.PubKey()
		if err != nil {
			u.redirectToLogin(w, r)
			return
		}
		claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer)
		if err != nil {
			u.clearSessionCookie(w)
			u.redirectToLogin(w, r)
			return
		}

		// Check revocation.
		rec, err := u.db.GetTokenRecord(claims.JTI)
		if err != nil || rec.IsRevoked() {
			u.clearSessionCookie(w)
			u.redirectToLogin(w, r)
			return
		}

		ctx := contextWithClaims(r.Context(), claims)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// requireCSRF validates the CSRF token on mutating requests (POST/PUT/PATCH/DELETE).
// The token is read from X-CSRF-Token header (HTMX) or _csrf form field (fallback).
func (u *UIServer) requireCSRF(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cookie, err := r.Cookie(csrfCookieName)
		if err != nil || cookie.Value == "" {
			http.Error(w, "CSRF cookie missing", http.StatusForbidden)
			return
		}

		// Header takes precedence (HTMX sets it automatically via hx-headers on body).
		formVal := r.Header.Get("X-CSRF-Token")
		if formVal == "" {
			// Fallback: parse form and read _csrf field.
			// Security: limit body size to prevent memory exhaustion (gosec G120).
			r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
			if parseErr := r.ParseForm(); parseErr == nil {
				formVal = r.FormValue("_csrf")
			}
		}

		if !u.csrf.Validate(cookie.Value, formVal) {
			http.Error(w, "CSRF token invalid", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// requireAdminRole checks that the authenticated user holds the "admin" role.
// Must be placed after requireCookieAuth in the middleware chain so that
// claims are available in the context.
//
// Security: This is the authoritative server-side check that prevents
// non-admin users from accessing admin-only UI endpoints. The JWT claims
// are populated from the database at login/renewal and signed with the
// server's Ed25519 private key, so they cannot be forged client-side.
func (u *UIServer) requireAdminRole(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		claims := claimsFromContext(r.Context())
		if claims == nil || !claims.HasRole("admin") {
			u.renderError(w, r, http.StatusForbidden, "admin role required")
			return
		}
		next.ServeHTTP(w, r)
	})
}

// ---- Helpers ----

// isHTMX reports whether the request was initiated by HTMX.
func isHTMX(r *http.Request) bool {
	return r.Header.Get("HX-Request") == "true"
}

// redirectToLogin redirects to the login page, using HX-Redirect for HTMX.
func (u *UIServer) redirectToLogin(w http.ResponseWriter, r *http.Request) {
	if isHTMX(r) {
		w.Header().Set("HX-Redirect", "/login")
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	http.Redirect(w, r, "/login", http.StatusFound)
}

// clearSessionCookie expires the session cookie.
func (u *UIServer) clearSessionCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteStrictMode,
	})
}

// setCSRFCookies sets the mcias_csrf cookie and returns the header value to
// embed in the page/form.
func (u *UIServer) setCSRFCookies(w http.ResponseWriter) (string, error) {
	cookieVal, headerVal, err := u.csrf.NewToken()
	if err != nil {
		return "", err
	}
	http.SetCookie(w, &http.Cookie{
		Name:  csrfCookieName,
		Value: cookieVal,
		Path:  "/",
		// Security: non-HttpOnly so that HTMX can embed it in hx-headers;
		// SameSite=Strict is the primary CSRF defence for browser requests.
		HttpOnly: false,
		Secure:   true,
		SameSite: http.SameSiteStrictMode,
	})
	return headerVal, nil
}

// render executes the named template, writing the result to w.
// Renders to a buffer first so partial template failures don't corrupt output.
// For page templates (dashboard, accounts, etc.) the page-specific template set
// is used; for fragment templates the name is looked up across all sets.
func (u *UIServer) render(w http.ResponseWriter, name string, data interface{}) {
	tmpl := u.templateFor(name)
	if tmpl == nil {
		u.logger.Error("template not found", "template", name)
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
	var buf bytes.Buffer
	if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
		u.logger.Error("template render error", "template", name, "error", err)
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_, _ = w.Write(buf.Bytes())
}

// templateFor returns the template set that contains the named template.
// Page templates have a dedicated set; fragment templates exist in every set.
func (u *UIServer) templateFor(name string) *template.Template {
	if t, ok := u.tmpls[name]; ok {
		return t
	}
	// Fragment — available in any page set; pick the first one.
	for _, t := range u.tmpls {
		if t.Lookup(name) != nil {
			return t
		}
	}
	return nil
}

// renderError returns an error response appropriate for the request type.
func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status int, msg string) {
	if isHTMX(r) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.WriteHeader(status)
		_, _ = fmt.Fprintf(w, `<div class="alert alert-error" role="alert">%s</div>`, template.HTMLEscapeString(msg))
		return
	}
	http.Error(w, msg, status)
}

// maxFormBytes limits the size of UI form submissions (1 MiB).
// Security: prevents memory exhaustion from oversized POST bodies (gosec G120).
const maxFormBytes = 1 << 20

// noDirListing wraps an http.Handler (typically http.FileServerFS) to return
// 404 for directory requests instead of an auto-generated directory index.
//
// Security (SEC-07): directory listings expose the names of all static assets,
// leaking framework and version information to attackers.
func noDirListing(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.HasSuffix(r.URL.Path, "/") || r.URL.Path == "" {
			http.NotFound(w, r)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// securityHeaders returns middleware that adds defensive HTTP headers to every
// UI response.
//
// Security rationale for each header:
//   - Content-Security-Policy: restricts resource loading to same origin only,
//     mitigating XSS escalation even if an attacker injects HTML (e.g. via a
//     malicious stored username rendered in the admin panel).
//   - X-Content-Type-Options: prevents MIME-sniffing; browsers must honour the
//     declared Content-Type and not guess an executable type from body content.
//   - X-Frame-Options: blocks the admin panel from being framed by a third-party
//     origin, preventing clickjacking against admin actions.
//   - Strict-Transport-Security: instructs browsers to use HTTPS for all future
//     requests to this origin for two years, preventing TLS-strip on revisit.
//   - Referrer-Policy: suppresses the Referer header on outbound navigations so
//     JWTs or session identifiers embedded in URLs are not leaked to third parties.
//   - Permissions-Policy: disables browser features (camera, microphone,
//     geolocation, payment) that this application does not use, reducing the
//     attack surface if a content-injection vulnerability is exploited.
func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := w.Header()
		// Security: 'unsafe-hashes' with the htmx swap indicator style hash
		// allows htmx to apply its settling/swapping CSS transitions without
		// opening the door to arbitrary inline styles.
		h.Set("Content-Security-Policy",
			"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-hashes' 'sha256-bsV5JivYxvGywDAZ22EZJKBFip65Ng9xoJVLbBg7bdo='; img-src 'self' data:; font-src 'self'")
		h.Set("X-Content-Type-Options", "nosniff")
		h.Set("X-Frame-Options", "DENY")
		h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
		h.Set("Referrer-Policy", "no-referrer")
		h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
		next.ServeHTTP(w, r)
	})
}

// clientIP returns the real client IP for the request, respecting the
// server's trusted-proxy setting (DEF-03).  Delegates to middleware.ClientIP
// so the same extraction logic is used for rate limiting and audit logging.
func (u *UIServer) clientIP(r *http.Request) string {
	var proxyIP net.IP
	if u.cfg.Server.TrustedProxy != "" {
		proxyIP = net.ParseIP(u.cfg.Server.TrustedProxy)
	}
	return middleware.ClientIP(r, proxyIP)
}

// isAdmin reports whether the authenticated user holds the "admin" role.
// Returns false if claims are absent.
func isAdmin(r *http.Request) bool {
	claims := claimsFromContext(r.Context())
	return claims != nil && claims.HasRole("admin")
}

// actorName resolves the username of the currently authenticated user from the
// request context.  Returns an empty string if claims are absent or the account
// cannot be found; callers should treat an empty string as "not logged in".
func (u *UIServer) actorName(r *http.Request) string {
	claims := claimsFromContext(r.Context())
	if claims == nil {
		return ""
	}
	acct, err := u.db.GetAccountByUUID(claims.Subject)
	if err != nil {
		return ""
	}
	return acct.Username
}

// ---- Page data types ----

// PageData is embedded in all page-level view structs.
type PageData struct {
	CSRFToken string
	Flash     string
	Error     string
	// ActorName is the username of the currently logged-in user, populated by
	// handlers so the base template can display it in the navigation bar.
	ActorName string
	// IsAdmin is true when the logged-in user holds the "admin" role.
	// Used by the base template to conditionally render admin-only navigation
	// links (SEC-09: non-admin users must not see links they cannot access).
	IsAdmin bool
}

// LoginData is the view model for the login page.
type LoginData struct {
	Error    string
	Username string // pre-filled on TOTP step
	SSONonce string // SSO session nonce (hidden field for SSO redirect flow)
	// Security (F-02): Password is no longer carried in the HTML form. Instead
	// a short-lived server-side nonce is issued after successful password
	// verification, and only the nonce is embedded in the TOTP step form.
	Nonce string // single-use server-side nonce replacing the password hidden field
	// WebAuthnEnabled indicates whether the passkey login button should appear.
	WebAuthnEnabled bool
}

// DashboardData is the view model for the dashboard page.
type DashboardData struct {
	PageData
	RecentEvents   []*db.AuditEventView
	TotalAccounts  int
	ActiveAccounts int
}

// AccountsData is the view model for the accounts list page.
type AccountsData struct {
	PageData
	Accounts []*model.Account
}

// AccountDetailData is the view model for the account detail page.
type AccountDetailData struct { //nolint:govet // fieldalignment: readability over alignment for view model
	Account *model.Account
	// PGCred is nil if none stored or the account is not a system account.
	PGCred *model.PGCredential
	// PGCredGrants lists accounts that have been granted read access to PGCred.
	// Only populated when the viewing actor is the credential owner.
	PGCredGrants []*model.PGCredAccessGrant
	// GrantableAccounts is the list of accounts the owner may grant access to.
	GrantableAccounts []*model.Account
	// ActorID is the DB id of the currently logged-in user; used in templates
	// to decide whether to show the owner-only management controls.
	ActorID *int64
	// TokenDelegates lists accounts that may issue tokens for this service account.
	// Only populated for system accounts when viewed by an admin.
	TokenDelegates []*model.ServiceAccountDelegate
	// DelegatableAccounts is the list of human accounts available for the
	// "add delegate" dropdown.  Only populated for admins.
	DelegatableAccounts []*model.Account
	// DownloadNonce is a one-time nonce for downloading the just-issued token.
	// Populated by handleIssueSystemToken; empty otherwise.
	DownloadNonce string
	PageData
	Roles    []string
	AllRoles []string
	Tags     []string
	Tokens   []*model.TokenRecord
	// WebAuthnCreds lists the WebAuthn credentials for this account (metadata only).
	WebAuthnCreds []*model.WebAuthnCredential
	// DeletePrefix is the URL prefix for WebAuthn credential delete buttons.
	DeletePrefix string
	// CanIssueToken is true when the viewing actor may issue tokens for this
	// system account (admin role or explicit delegate grant).
	// Placed last to minimise GC scan area.
	CanIssueToken   bool
	WebAuthnEnabled bool
}

// ServiceAccountsData is the view model for the /service-accounts page.
// It shows the system accounts for which the current user has delegate access,
// plus the just-issued token download nonce (if a token was just issued).
type ServiceAccountsData struct {
	// Accounts is the list of system accounts the actor may issue tokens for.
	Accounts []*model.Account
	// DownloadNonce is a one-time nonce for downloading the just-issued token.
	// Non-empty immediately after a successful token issuance.
	DownloadNonce string
	// IssuedFor is the UUID of the account whose token was just issued.
	IssuedFor string
	PageData
}

// AuditData is the view model for the audit log page.
type AuditData struct {
	PageData
	FilterType string
	Events     []*db.AuditEventView
	EventTypes []string
	Total      int64
	TotalPages int
	Page       int
}

// AuditDetailData is the view model for a single audit event detail page.
type AuditDetailData struct {
	Event *db.AuditEventView
	PageData
}

// PolicyRuleView is a single policy rule prepared for template rendering.
type PolicyRuleView struct {
	Description string
	RuleJSON    string
	CreatedAt   string
	UpdatedAt   string
	NotBefore   string // empty if not set
	ExpiresAt   string // empty if not set
	ID          int64
	Priority    int
	Enabled     bool
	IsExpired   bool // true if expires_at is in the past
	IsPending   bool // true if not_before is in the future
}

// SSOClientsData is the view model for the SSO clients admin page.
type SSOClientsData struct {
	PageData
	Clients []*model.SSOClient
}

// PoliciesData is the view model for the policies list page.
type PoliciesData struct {
	PageData
	Rules      []*PolicyRuleView
	AllActions []string
}

// ProfileData is the view model for the profile/settings page.
type ProfileData struct { //nolint:govet // fieldalignment: readability over alignment for view model
	PageData
	WebAuthnCreds   []*model.WebAuthnCredential
	DeletePrefix    string // URL prefix for delete buttons (e.g. "/profile/webauthn")
	WebAuthnEnabled bool
	// TOTP enrollment fields (populated only during enrollment flow).
	TOTPEnabled     bool
	TOTPSecret      string       // base32-encoded; shown once during enrollment
	TOTPQR          template.URL // data:image/png;base64,... QR code; template.URL bypasses URL escaping
	TOTPEnrollNonce string       // single-use nonce for confirm step
	TOTPError       string       // enrollment-specific error message
	TOTPSuccess     string       // success flash after confirmation
}

// PGCredsData is the view model for the "My PG Credentials" list page.
// It shows all pg_credentials sets accessible to the currently logged-in user:
// those they own and those they have been granted access to.
// UncredentialedAccounts is the list of system accounts that have no credentials
// yet, populated to drive the "New Credentials" create form on the same page.
// CredGrants maps credential ID to the list of access grants for that credential;
// only populated for credentials the actor owns.
// AllAccounts is used to populate the grant-access dropdown for owned credentials.
// ActorID is the DB id of the currently logged-in user.
type PGCredsData struct {
	CredGrants map[int64][]*model.PGCredAccessGrant
	ActorID    *int64
	PageData
	Creds                  []*model.PGCredential
	UncredentialedAccounts []*model.Account
	AllAccounts            []*model.Account
}
