package http

import (
	"context"
	"encoding/json"
	"errors"
	"html/template"
	"log/slog"
	"math"
	stdhttp "net/http"
	"strconv"
	"strings"
	"time"

	"github.com/oklog/ulid/v2"

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

// ui_repo.go — HTML form-driven repo-tab handlers (connection,
// bandwidth caps, maintenance cadences, danger-zone re-init). Splits
// the page into three independent forms so saving one section
// doesn't disturb the others.
//
//   GET  /hosts/{id}/repo                            — render
//   POST /hosts/{id}/repo/credentials                — connection
//   POST /hosts/{id}/repo/bandwidth                  — host-wide bw caps
//   POST /hosts/{id}/repo/maintenance                — forget/prune/check cadences
//   POST /hosts/{id}/admin-credentials               — admin (prune) creds
//   POST /hosts/{id}/admin-credentials/delete        — clear admin creds

// repoTrendView is the data the repo_size_chart partial needs.
// HostID + Range round-trip through the htmx range pills; ChartSVG
// is pre-rendered server-side so the partial is just a wrapper.
type repoTrendView struct {
	HostID   string
	Range    string
	ChartSVG template.HTML
}

// repoStatsView is a flat, pre-dereferenced projection of
// store.HostRepoStats for use in templates. Nil pointer fields are
// collapsed to zero/false and accompanied by a Has* sentinel so the
// template can distinguish "zero" from "not yet known."
type repoStatsView struct {
	HasTotalSize    bool
	TotalSizeBytes  int64
	HasRawSize      bool
	RawSizeBytes    int64
	HasLastCheck    bool
	LastCheckAt     time.Time
	LastCheckAgo    string
	LastCheckStatus string
	LockPresent     bool
	HasLastPrune    bool
	LastPruneAt     time.Time
	LastPruneAgo    string
}

type hostRepoPage struct {
	hostChromeData

	// Connection (redacted view)
	RepoURL      string
	RepoUsername string
	HasPassword  bool

	// Admin credentials (optional, prune-only — separate slot).
	AdminURL         string
	AdminUsername    string
	HasAdminPassword bool

	// Bandwidth (form values, blank means "no cap")
	BandwidthUp   string
	BandwidthDown string

	// Maintenance row
	Maintenance store.HostRepoMaintenance

	// Online mirrors Hub.Connected so Run-now button disabled state is
	// accurate at render time.
	Online bool

	// StatsView is a pre-dereferenced projection of host_repo_stats.
	// Nil when no row exists yet (fresh hosts).
	StatsView *repoStatsView

	// Trend holds the pre-rendered chart fragment data for the
	// 30/90/365-day repo-size + snapshot-count overlay chart.
	Trend repoTrendView

	// Snapshots-by-tag — map[group_name]count, plus an "untagged" row.
	SnapshotsByTag    map[string]int
	UntaggedSnapshots int
	GroupNames        []string // ordered, for stable rendering

	// Host-default hooks (decrypted plaintext for round-trip in form).
	HostPreHook  string
	HostPostHook string

	// Inline form-error banners. Empty when no error for that section.
	CredentialsError string
	AdminCredsError  string
	BandwidthError   string
	MaintenanceError string
	HooksError       string

	// Highlight which form was just submitted, for the success-state
	// border (subtle UX nicety; empty = no recent save).
	SavedSection string
}

// loadHostRepoPage builds the read-only side of the page state. The
// per-form save handlers re-call this and overlay any banner / saved
// markers before rendering.
func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRepoPage, error) {
	p := &hostRepoPage{
		hostChromeData: s.loadHostChrome(r, host, "repo", "repo"),
	}

	// Credentials (redacted).
	enc, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindRepo)
	switch {
	case err == nil:
		plain, derr := s.deps.AEAD.Decrypt(enc, []byte("host:"+host.ID))
		if derr == nil {
			var blob repoCredsBlob
			if jerr := json.Unmarshal(plain, &blob); jerr == nil {
				p.RepoURL = blob.RepoURL
				p.RepoUsername = blob.RepoUsername
				p.HasPassword = blob.RepoPassword != ""
			}
		}
	case errors.Is(err, store.ErrNotFound):
		// no creds yet — leave fields empty
	default:
		return nil, err
	}

	// Admin credentials (optional — prune-only slot).
	adminEnc, aerr := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindAdmin)
	switch {
	case aerr == nil:
		plain, derr := s.deps.AEAD.Decrypt(adminEnc, []byte("host:"+host.ID+":admin"))
		if derr == nil {
			var blob repoCredsBlob
			if jerr := json.Unmarshal(plain, &blob); jerr == nil {
				p.AdminURL = blob.RepoURL
				p.AdminUsername = blob.RepoUsername
				p.HasAdminPassword = blob.RepoPassword != ""
			}
		}
	case errors.Is(aerr, store.ErrNotFound):
		// admin slot not configured — fine
	default:
		return nil, aerr
	}

	// Online status.
	if s.deps.Hub != nil {
		p.Online = s.deps.Hub.Connected(host.ID)
	}

	// Repo stats (tolerate ErrNotFound — fresh hosts have no row yet).
	if stats, serr := s.deps.Store.GetHostRepoStats(r.Context(), host.ID); serr == nil {
		sv := &repoStatsView{}
		if stats.TotalSizeBytes != nil {
			sv.HasTotalSize = true
			sv.TotalSizeBytes = *stats.TotalSizeBytes
		}
		if stats.RawSizeBytes != nil {
			sv.HasRawSize = true
			sv.RawSizeBytes = *stats.RawSizeBytes
		}
		if stats.LastCheckAt != nil {
			sv.HasLastCheck = true
			sv.LastCheckAt = *stats.LastCheckAt
			sv.LastCheckAgo = relTimeAgo(*stats.LastCheckAt)
		}
		sv.LastCheckStatus = stats.LastCheckStatus
		if stats.LockPresent != nil {
			sv.LockPresent = *stats.LockPresent
		}
		if stats.LastPruneAt != nil {
			sv.HasLastPrune = true
			sv.LastPruneAt = *stats.LastPruneAt
			sv.LastPruneAgo = relTimeAgo(*stats.LastPruneAt)
		}
		p.StatsView = sv
	} else if !errors.Is(serr, store.ErrNotFound) {
		return nil, serr
	}

	// Bandwidth.
	if host.BandwidthUpKBps != nil {
		p.BandwidthUp = strconv.Itoa(*host.BandwidthUpKBps)
	}
	if host.BandwidthDownKBps != nil {
		p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps)
	}

	// Host-default hooks (decrypt for round-trip in the form).
	p.HostPreHook = s.decryptHookOrFallback("", host.PreHookDefault, host.ID, "pre")
	p.HostPostHook = s.decryptHookOrFallback("", host.PostHookDefault, host.ID, "post")

	// Maintenance — auto-seed defaults if missing.
	m, err := s.deps.Store.GetRepoMaintenance(r.Context(), host.ID)
	if err != nil && errors.Is(err, store.ErrNotFound) {
		if seedErr := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); seedErr != nil {
			return nil, seedErr
		}
		m, err = s.deps.Store.GetRepoMaintenance(r.Context(), host.ID)
	}
	if err != nil {
		return nil, err
	}
	p.Maintenance = *m

	// Snapshot counts by tag — used for the right-rail breakdown.
	groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
	if err == nil {
		groupNameSet := make(map[string]struct{}, len(groups))
		for _, g := range groups {
			p.GroupNames = append(p.GroupNames, g.Name)
			groupNameSet[g.Name] = struct{}{}
		}
		if snaps, serr := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID); serr == nil {
			p.SnapshotsByTag = make(map[string]int, len(groups))
			for _, sn := range snaps {
				matched := false
				for _, t := range sn.Tags {
					if _, ok := groupNameSet[t]; ok {
						p.SnapshotsByTag[t]++
						matched = true
					}
				}
				if !matched {
					p.UntaggedSnapshots++
				}
			}
		}
	}
	p.Trend = s.buildRepoTrendView(r.Context(), host.ID, "30d")

	return p, nil
}

// buildRepoTrendView builds the chart-partial data for a host. Used
// both by the page-load (initial 30d render) and the htmx fragment
// endpoint (range switching). An invalid rangeKey falls back to "30d".
func (s *Server) buildRepoTrendView(ctx context.Context, hostID, rangeKey string) repoTrendView {
	days := 30
	switch rangeKey {
	case "90d":
		days = 90
	case "1y":
		days = 365
	default:
		rangeKey = "30d"
	}
	since := time.Now().UTC().AddDate(0, 0, -days)
	pts, err := s.deps.Store.ListHostRepoStatsHistory(ctx, hostID, since)
	if err != nil {
		slog.Warn("ui repo trend: list history", "host_id", hostID, "err", err)
	}
	sizes := make([]float64, len(pts))
	counts := make([]float64, len(pts))
	dayList := make([]time.Time, len(pts))
	for i, p := range pts {
		dayList[i] = p.Day
		if p.TotalSizeBytes == nil {
			sizes[i] = math.NaN()
		} else {
			sizes[i] = float64(*p.TotalSizeBytes)
		}
		if p.SnapshotCount == nil {
			counts[i] = math.NaN()
		} else {
			counts[i] = float64(*p.SnapshotCount)
		}
	}
	chartSVG := sparkline.RenderChart([]sparkline.Series{
		{Name: "size", Stroke: "#3b82f6", Axis: sparkline.AxisLeft, Format: sparkline.FormatBytes, Points: sizes},
		{Name: "snapshots", Stroke: "#f59e0b", Axis: sparkline.AxisRight, Format: sparkline.FormatCount, Points: counts},
	}, dayList, sparkline.ChartOpts{Width: 640, Height: 220})
	return repoTrendView{HostID: hostID, Range: rangeKey, ChartSVG: chartSVG}
}

func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	page, err := s.loadHostRepoPage(r, *host)
	if err != nil {
		slog.Error("ui repo: load page", "host_id", host.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	page.SavedSection = r.URL.Query().Get("saved")
	view := s.baseView(r, u)
	view.Title = host.Name + " repo · restic-manager"
	view.Page = *page
	if err := s.deps.UI.Render(w, "host_repo", view); err != nil {
		slog.Error("ui: render host_repo", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// renderRepoPage loads the page state, overlays section error banners,
// and renders with a 422. Save-success goes through a 303 redirect
// with `?saved=<section>` instead, so this path is for validation
// failures only.
func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, credErr, adminErr, bwErr, mntErr string) {
	page, err := s.loadHostRepoPage(r, *host)
	if err != nil {
		slog.Error("ui repo: reload after save", "host_id", host.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	page.CredentialsError = credErr
	page.AdminCredsError = adminErr
	page.BandwidthError = bwErr
	page.MaintenanceError = mntErr
	view := s.baseView(r, u)
	view.Title = host.Name + " repo · restic-manager"
	view.Page = *page
	w.WriteHeader(stdhttp.StatusUnprocessableEntity)
	if err := s.deps.UI.Render(w, "host_repo", view); err != nil {
		slog.Error("ui: render host_repo", "err", err)
	}
}

// handleUIRepoCredentialsSave updates the host's stored repo URL,
// username, and (optionally) password. Empty password means "leave
// the existing one alone" — passwords are never round-tripped to the
// browser, so a blank field is the only way an operator can save the
// other fields without re-typing the password.
func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	repoURL := strings.TrimSpace(r.PostForm.Get("repo_url"))
	repoUser := strings.TrimSpace(r.PostForm.Get("repo_username"))
	repoPass := r.PostForm.Get("repo_password") // do NOT trim — operators may use trailing space deliberately

	if repoURL == "" {
		s.renderRepoPage(w, r, u, host, "Repo URL is required.", "", "", "")
		return
	}

	// Merge with existing blob — same semantics as the JSON PUT.
	existing := repoCredsBlob{}
	if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindRepo); err == nil {
		if plain, derr := s.deps.AEAD.Decrypt(cur, []byte("host:"+host.ID)); derr == nil {
			_ = json.Unmarshal(plain, &existing)
		}
	}
	existing.RepoURL = repoURL
	existing.RepoUsername = repoUser
	if repoPass != "" {
		existing.RepoPassword = repoPass
	}
	if existing.RepoPassword == "" {
		s.renderRepoPage(w, r, u, host,
			"No password on file yet — set one before saving the URL/username.",
			"", "", "")
		return
	}

	enc, err := s.encryptRepoCreds(existing, []byte("host:"+host.ID))
	if err != nil {
		slog.Error("ui repo creds: encrypt", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, store.CredKindRepo, enc); err != nil {
		slog.Error("ui repo creds: persist", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	// NS-03: clear repo_status — the new creds may reach a different
	// repo or fix an auth typo, so any prior probe outcome is stale.
	if err := s.deps.Store.SetHostRepoStatus(r.Context(), host.ID, "unknown", ""); err != nil {
		slog.Warn("ui repo creds: reset repo_status", "host_id", host.ID, "err", err)
	}
	if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) {
		_ = s.pushRepoCredsToAgent(r.Context(), host.ID, existing)
		// NS-03: probe the new creds immediately — surface bad
		// password / wrong URL on the host detail page rather than at
		// the next scheduled job.
		if err := s.dispatchInitJob(r.Context(), host.ID, "user", &u.ID); err != nil {
			slog.Warn("ui repo creds: dispatch init", "host_id", host.ID, "err", err)
		}
	}
	stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=credentials", stdhttp.StatusSeeOther)
}

// handleUIRepoBandwidthSave updates the host's upload/download caps.
// Empty input → nil pointer → no cap. Negative → error.
func (s *Server) handleUIRepoBandwidthSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	up, upErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_up"))
	down, downErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_down"))
	if upErr != nil || downErr != nil {
		s.renderRepoPage(w, r, u, host, "", "",
			"Bandwidth caps must be non-negative whole numbers (or blank for no cap).",
			"")
		return
	}
	if err := s.deps.Store.SetHostBandwidth(r.Context(), host.ID, up, down); err != nil {
		slog.Error("ui repo bandwidth: persist", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=bandwidth", stdhttp.StatusSeeOther)
}

// handleUIRepoMaintenanceSave updates the forget/prune/check
// cadences in one go. Cron expressions parsed with the same parser
// the agent + REST handler use.
func (s *Server) handleUIRepoMaintenanceSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	forgetCron := strings.TrimSpace(r.PostForm.Get("forget_cron"))
	pruneCron := strings.TrimSpace(r.PostForm.Get("prune_cron"))
	checkCron := strings.TrimSpace(r.PostForm.Get("check_cron"))
	subsetStr := strings.TrimSpace(r.PostForm.Get("check_subset_pct"))

	for label, expr := range map[string]string{
		"forget": forgetCron, "prune": pruneCron, "check": checkCron,
	} {
		if expr == "" {
			s.renderRepoPage(w, r, u, host, "", "", "",
				label+" cadence is required.")
			return
		}
		if _, err := cronParser.Parse(expr); err != nil {
			s.renderRepoPage(w, r, u, host, "", "", "",
				label+" cadence didn't parse: "+err.Error())
			return
		}
	}
	subset, err := strconv.Atoi(subsetStr)
	if err != nil || subset < 0 || subset > 100 {
		s.renderRepoPage(w, r, u, host, "", "", "",
			"check subset % must be between 0 and 100.")
		return
	}

	if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); err != nil {
		slog.Error("ui repo maintenance: seed", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	m := store.HostRepoMaintenance{
		HostID:         host.ID,
		ForgetCron:     forgetCron,
		ForgetEnabled:  r.PostForm.Get("forget_enabled") == "1",
		PruneCron:      pruneCron,
		PruneEnabled:   r.PostForm.Get("prune_enabled") == "1",
		CheckCron:      checkCron,
		CheckEnabled:   r.PostForm.Get("check_enabled") == "1",
		CheckSubsetPct: subset,
	}
	if err := s.deps.Store.UpdateRepoMaintenance(r.Context(), &m); err != nil {
		slog.Error("ui repo maintenance: persist", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=maintenance", stdhttp.StatusSeeOther)
}

// parseOptionalNonNegInt returns (nil, nil) for an empty string, or
// (*int, nil) for a non-negative integer. Negative or non-numeric →
// error. Used for bandwidth caps where blank means "no limit".
func parseOptionalNonNegInt(s string) (*int, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return nil, nil
	}
	n, err := strconv.Atoi(s)
	if err != nil || n < 0 {
		return nil, errors.New("invalid")
	}
	return &n, nil
}

// relTimeAgo returns a short human-readable relative-time string like
// "5m ago", "3h ago", "2d ago" for use in stats panels. Does not use
// the template funcMap so it can be called from Go directly.
func relTimeAgo(t time.Time) string {
	d := time.Since(t)
	if d < 0 {
		d = 0
	}
	switch {
	case d < time.Minute:
		return "just now"
	case d < time.Hour:
		return strconv.Itoa(int(d.Minutes())) + "m ago"
	case d < 24*time.Hour:
		return strconv.Itoa(int(d.Hours())) + "h ago"
	case d < 30*24*time.Hour:
		return strconv.Itoa(int(d.Hours()/24)) + "d ago"
	default:
		return t.Format("2006-01-02")
	}
}

// handleUIAdminCredentialsSave handles the HTML form POST to
// /hosts/{id}/admin-credentials. Mirrors handleUIRepoCredentialsSave
// but operates on the admin slot (store.CredKindAdmin, AAD "host:<id>:admin").
// Re-renders the page with an inline error on validation failure;
// redirects with ?saved=admin_credentials on success.
func (s *Server) handleUIAdminCredentialsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	repoURL := strings.TrimSpace(r.PostForm.Get("repo_url"))
	repoUser := strings.TrimSpace(r.PostForm.Get("repo_username"))
	repoPass := r.PostForm.Get("repo_password")

	// All blank → no-op save (operator hit Save without filling anything).
	// We treat this as harmless — they may have wanted to clear via the
	// Clear button instead. Only validate if they've started filling fields.
	if repoURL == "" && repoUser == "" && repoPass == "" {
		stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo", stdhttp.StatusSeeOther)
		return
	}

	aad := []byte("host:" + host.ID + ":admin")

	// Merge with the existing admin row, if any.
	existing := repoCredsBlob{}
	if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindAdmin); err == nil {
		if plain, derr := s.deps.AEAD.Decrypt(cur, aad); derr == nil {
			_ = json.Unmarshal(plain, &existing)
		}
	}
	existing.RepoURL = repoURL
	existing.RepoUsername = repoUser
	if repoPass != "" {
		existing.RepoPassword = repoPass
	}

	if existing.RepoURL == "" {
		s.renderRepoPage(w, r, u, host, "", "Repo URL is required.", "", "")
		return
	}
	if existing.RepoPassword == "" {
		s.renderRepoPage(w, r, u, host, "",
			"No password on file yet — set one before saving the URL/username.",
			"", "")
		return
	}

	enc, err := s.encryptRepoCreds(existing, aad)
	if err != nil {
		slog.Error("ui admin creds: encrypt", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, store.CredKindAdmin, enc); err != nil {
		slog.Error("ui admin creds: persist", "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.admin_credentials_set",
		TargetKind: ptr("host"),
		TargetID:   &host.ID,
		TS:         nowUTC(),
	})
	if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) {
		if perr := s.pushAdminCredsToAgent(r.Context(), host.ID); perr != nil {
			slog.Warn("ui admin creds: push to agent", "host_id", host.ID, "err", perr)
		}
	}
	stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther)
}

// handleUIAdminCredentialsDelete handles the HTML form POST to
// /hosts/{id}/admin-credentials/delete. Removes the admin slot and
// redirects back to the repo page. Treats "not found" as success
// (idempotent delete from the operator's point of view).
func (s *Server) handleUIAdminCredentialsDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}

	err := s.deps.Store.DeleteHostCredentials(r.Context(), host.ID, store.CredKindAdmin)
	if err != nil && !errors.Is(err, store.ErrNotFound) {
		slog.Error("ui admin creds: delete", "host_id", host.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if err == nil {
		_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
			ID:         ulid.Make().String(),
			UserID:     &u.ID,
			Actor:      "user",
			Action:     "host.admin_credentials_deleted",
			TargetKind: ptr("host"),
			TargetID:   &host.ID,
			TS:         nowUTC(),
		})
	}
	stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther)
}
