package web

import (
	"database/sql"
	"encoding/json"
	"errors"
	"net/http"
	"sort"
	"strings"
	"time"

	"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

// agentOrUnknown renders an agent version for operator text, mapping the empty (never-reported)
// value to a readable token.
func agentOrUnknown(v string) string {
	if v == "" {
		return "unknown"
	}
	return v
}

// hostStatus computes the host's liveness state from its last-report recency, using the
// SAME thresholds as the HostStalenessChecker (s.staleThreshold; "down" at 2×). This keeps
// the GUI badge in agreement with the alerting — there is no second definition of "stale".
// Returns one of: "ok" (online), "stale", "down", "pending" (never reported).
func (s *Server) hostStatus(lastReport *time.Time) string {
	if lastReport == nil {
		return "pending"
	}
	age := time.Since(*lastReport)
	switch {
	case age > 2*s.staleThreshold:
		return "down"
	case age > s.staleThreshold:
		return "stale"
	default:
		return "ok"
	}
}

// hostStatusClass maps the internal status to the existing status-badge-* CSS class.
// "stale" reuses the amber -warn class (there is no dedicated -stale class), keeping the
// styling consistent with the rest of the console.
func hostStatusClass(status string) string {
	switch status {
	case "ok":
		return "status-badge-ok"
	case "stale":
		return "status-badge-warn"
	case "down":
		return "status-badge-down"
	default:
		return "status-badge-pending"
	}
}

// hostStatusLabel maps the internal status to the operator-facing badge label.
func hostStatusLabel(status string) string {
	switch status {
	case "ok":
		return "ONLINE"
	case "stale":
		return "STALE"
	case "down":
		return "DOWN"
	default:
		return "NO REPORT"
	}
}

// hostVitals are the report-body fields the Hosts views surface (CPU/mem/disk +
// cloudflared). Parsed from the latest host-report's report_json — the same body the
// checkers read; no new ingestion path. Zero values when there is no report.
type hostVitals struct {
	CPUPercent        float64
	MemoryPercent     float64
	DiskPercent       float64
	CloudflaredStatus string
}

// parseHostVitals extracts the vitals block from a host-report body. A missing/malformed
// body yields zero vitals (never a panic) — the "waiting for first report" path.
func parseHostVitals(reportJSON string) hostVitals {
	var v hostVitals
	if reportJSON == "" {
		return v
	}
	var body struct {
		Host struct {
			CPUPercent    float64 `json:"cpu_percent"`
			MemoryPercent float64 `json:"memory_percent"`
			DiskPercent   float64 `json:"disk_percent"`
		} `json:"host"`
		Cloudflared struct {
			Status string `json:"status"`
		} `json:"cloudflared"`
	}
	if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
		return v
	}
	v.CPUPercent = body.Host.CPUPercent
	v.MemoryPercent = body.Host.MemoryPercent
	v.DiskPercent = body.Host.DiskPercent
	v.CloudflaredStatus = body.Cloudflared.Status
	return v
}

// capabilityView is one privileged-capability chip on the host detail page (v0.51.0 — the agent
// has reported these since v0.44.0; the hub now renders them). Class maps the agent's status to
// a badge: ok → badge-ok, degraded → badge-fail (critical) / badge-warn, inactive → badge-neutral
// (disabled ≠ degraded — the DR-tier-by-default rule; agent v0.86.0 emits "inactive").
type capabilityView struct {
	Name     string
	Feature  string
	Status   string
	Reason   string
	Critical bool
	Class    string
}

// parseHostCapabilities extracts the capabilities array from a host-report body. Missing or
// malformed → nil (the "waiting for first report" path — the section hides).
func parseHostCapabilities(reportJSON string) []capabilityView {
	if reportJSON == "" {
		return nil
	}
	var body struct {
		Capabilities []struct {
			Name     string `json:"name"`
			Feature  string `json:"feature"`
			Critical bool   `json:"critical"`
			Status   string `json:"status"`
			Reason   string `json:"reason"`
		} `json:"capabilities"`
	}
	if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
		return nil
	}
	out := make([]capabilityView, 0, len(body.Capabilities))
	for _, c := range body.Capabilities {
		v := capabilityView{Name: c.Name, Feature: c.Feature, Status: c.Status, Reason: c.Reason, Critical: c.Critical}
		switch c.Status {
		case "ok":
			v.Class = "badge-ok"
		case "inactive":
			v.Class = "badge-neutral"
		default: // degraded (or an unknown future status — surface it, never hide it)
			if c.Critical {
				v.Class = "badge-error"
			} else {
				v.Class = "badge-warn"
			}
		}
		out = append(out, v)
	}
	return out
}

// capabilitiesNeedDRMigration reports whether any pbsdr-* capability is degraded with the
// pre-v1.15.0 signature ("binary not found") — the box predates the uniform DR plumbing. The
// host page then surfaces the migration one-liner instead of silently pretending (§8 of the
// DR-by-default spec).
func capabilitiesNeedDRMigration(caps []capabilityView) bool {
	for _, c := range caps {
		if strings.HasPrefix(c.Name, "pbsdr-") && c.Status == "degraded" && c.Reason == "binary not found" {
			return true
		}
	}
	return false
}

// --- Network (v0.85.0): the host's addresses + its WireGuard allocation ---

// minAgentForAddresses is the agent release that first reported `addresses[]`. Below it the field is
// absent from the wire, which is UNKNOWN and must never render as "this host has no addresses" —
// the presence-is-not-result rule (CLAUDE.md): an absent signal and a negative result are different
// facts, and a page that conflates them tells the operator something false.
const minAgentForAddresses = "0.119.0"

// hostAddressView is one (interface, address) row from the report.
type hostAddressView struct {
	Iface string
	CIDR  string
	IP    string // the bare address, for comparison against the WG allocation
}

// parseHostAddresses extracts addresses[] from a host-report body. Missing/malformed → empty
// (never a panic) — the "waiting for first report" / old-agent path.
func parseHostAddresses(reportJSON string) []hostAddressView {
	out := []hostAddressView{}
	if reportJSON == "" {
		return out
	}
	var body struct {
		Addresses []struct {
			Iface string `json:"iface"`
			CIDR  string `json:"cidr"`
		} `json:"addresses"`
	}
	if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
		return out
	}
	for _, a := range body.Addresses {
		v := hostAddressView{Iface: a.Iface, CIDR: a.CIDR}
		if ip, _, ok := strings.Cut(a.CIDR, "/"); ok {
			v.IP = ip
		} else {
			v.IP = a.CIDR
		}
		out = append(out, v)
	}
	return out
}

// hostNetworkView is the Network card's whole view-model.
//
// The WireGuard half is deliberately TWO facts, not one: WGAssignedIP is the hub's own allocation
// (wg_peers — desired state, authoritative) and WGConfirmed says whether the box actually reports
// holding it. Rendering only the allocation would make a silently-unapplied peer look healthy; that
// is the same shape as a timestamp recording an attempt being read as a result.
type hostNetworkView struct {
	Addresses    []hostAddressView // non-WireGuard addresses (the LAN bridge, a tailnet)
	WGAssignedIP string            // hub allocation; "" when this host has no peer
	WGConfirmed  bool              // the box reports an address equal to WGAssignedIP
	Reported     bool              // the agent is new enough to report addresses at all
	AgentTooOld  bool              // it is NOT — so the empty list means UNKNOWN, not none
}

// hostNetwork builds the Network card's view-model from the report + the hub's peer allocation.
//
// The WireGuard address is split out by comparing against the hub's allocation rather than by
// matching an interface NAME: "wg-felhom" is the agent's current unit name, and keying a UI on it
// would silently mis-render the day that changes. The allocation is the identity that survives.
func (s *Server) hostNetwork(host *store.Host, reportJSON string) hostNetworkView {
	v := hostNetworkView{Addresses: []hostAddressView{}}

	if peer, err := s.store.GetWGPeerForHost(host.HostID); err == nil && peer != nil {
		v.WGAssignedIP = peer.AssignedIP
	} else if err != nil && !errors.Is(err, sql.ErrNoRows) {
		// A real store failure must not read as "this host has no tunnel".
		s.logger.Printf("[ERROR] host network %s: wg peer: %v", host.HostID, err)
	}

	// An agent older than minAgentForAddresses does not send the field at all. Say so, rather than
	// rendering an empty list that looks like a finding.
	if host.AgentVersion != "" && semver.Valid(host.AgentVersion) &&
		semver.Compare(host.AgentVersion, minAgentForAddresses) < 0 {
		v.AgentTooOld = true
		return v
	}

	for _, a := range parseHostAddresses(reportJSON) {
		if v.WGAssignedIP != "" && a.IP == v.WGAssignedIP {
			v.WGConfirmed = true
			continue // shown in the WireGuard row, not repeated in the address list
		}
		v.Addresses = append(v.Addresses, a)
	}
	v.Reported = len(v.Addresses) > 0 || v.WGConfirmed
	return v
}

// ── R-319 — the guest-network watchdog gets a reader ────────────────────────────────────────────
//
// FIRST OF THE R-264 READERS. The agent has reported `guest_net` on every heartbeat since v0.92.0
// (R-54, 2026-07-21) and the hub — the component that emails the operator — modelled NONE of it: the
// string `guest_net` occurred nowhere in this repository. It was stored as raw text inside
// `report_json` and read by nothing.
//
// WHY THIS ONE FIRST. It has a live incident behind it: a killed `dhclient` in a guest took a tunnel
// down for 1 h 15 m with nobody told (`audits/INCIDENT-guest-dhclient-killed-2026-07-20.md`), and the
// watchdog built afterwards has been reporting exactly that condition ever since — to a hub that
// modelled none of it. R-264 named it "the strongest candidate of the twenty-one".
//
// WHAT IS ACTUALLY BEING READ, and it is NOT just the current state. The signal is
// `heals_last_hour`: a guest whose network the watchdog keeps REPAIRING is healthy at every instant
// anyone looks and is nevertheless failing. Rendering only `state` would give that machine a green
// tick — which is the exact shape of the defect that drew a failed disk as a healthy empty disk.
// RepairCount is therefore surfaced beside the state, not behind it.
//
// AN UNKNOWN IS NEVER DRAWN AS HEALTHY. Three distinct absences are kept apart, following the
// companion-flag convention this project settled in August (`hostNetworkView` above is the sibling):
//   - AgentTooOld  — the agent predates v0.92.0, so absence is EXPECTED but still tells us nothing;
//   - Reported=false — a new-enough agent sent no stanza (watchdog disabled, or a report that
//     predates the feature on this box);
//   - a guest whose `state` is empty or unrecognised → rendered "unknown", never healthy.
// A malformed stanza degrades to Reported=false and must never 500 the page.

// minAgentForGuestNet is the agent release that first reported `guest_net` (v0.92.0, R-54). Below it
// the stanza is absent BY CONSTRUCTION, so its absence is unknown-and-expected rather than a finding.
const minAgentForGuestNet = "0.92.0"

// guestNetGuestView is one owned guest's network health as the watchdog last saw it.
type guestNetGuestView struct {
	VMID          int
	State         string // healthy | unhealthy | static_fault | unknown ("" → unknown)
	Mode          string // dhcp | static | unknown
	IP            string
	HasRoute      bool
	DHClientAlive bool
	// RepairCount is `heals_last_hour` — THE SIGNAL. A machine repairing itself over and over is
	// telling you something that its current state cannot.
	RepairCount int
	LastHealAt  string
	Damped      bool
	Message     string
	// Healed / HealSucceeded are carried DELIBERATELY, and together. R-260 is this project's
	// warning: the OOB decoder mirrored five of the agent's eight fields, and the three it dropped
	// included the one that decided the question the checker existed to answer. A repair that FAILED
	// is a different fact from a repair that worked, and the count alone cannot express it — six
	// successful repairs is a nuisance, six FAILED ones is a guest that is down right now.
	Healed        bool
	HealSucceeded bool
}

// HealFailed reports that the watchdog TRIED to repair this guest and did not succeed. Kept separate
// from State because the two can disagree: the sweep that failed to heal is not necessarily the sweep
// that set the state.
func (g guestNetGuestView) HealFailed() bool { return g.Healed && !g.HealSucceeded }

// Unknown reports whether this guest's state is one the watchdog did not positively assert. An empty
// or unrecognised state is drawn as unknown; it is never allowed to fall through to the healthy
// branch. Value receiver — a pointer receiver compiles, vets, passes the suite and 500s at render.
func (g guestNetGuestView) Unknown() bool {
	switch g.State {
	case "healthy", "unhealthy", "static_fault":
		return false
	default:
		return true
	}
}

// Repairing reports whether the watchdog has had to repair this guest inside the last hour. This is
// deliberately independent of State: the whole point is that a REPAIRED guest reads healthy.
func (g guestNetGuestView) Repairing() bool { return g.RepairCount > 0 }

// guestNetView is the Guest network card's whole view-model.
type guestNetView struct {
	// Reported is the ONLY thing that licenses drawing any health at all. False → unknown.
	Reported bool
	// AgentTooOld distinguishes "this agent cannot report it" from "a capable agent said nothing".
	// Both render as unknown; they need different words, and conflating them is how an operator
	// starts ignoring the card.
	AgentTooOld bool
	CheckedAt   string
	Guests      []guestNetGuestView
	// RepairingCount / UnhealthyCount drive the card's summary badge. Counted rather than derived in
	// the template: template logic that computes a verdict is logic nobody tests.
	RepairingCount int
	UnhealthyCount int
	UnknownCount   int
	// HealFailedCount — repairs ATTEMPTED and not succeeded. Counted separately from
	// RepairingCount: a failing repair is a harder fact than a frequent one.
	HealFailedCount int
}

// Degraded is true when any owned guest is unhealthy OR is being repeatedly repaired. The repair leg
// is the one that matters: without it a guest the watchdog fixes every ten minutes reports "healthy"
// for ever.
func (v guestNetView) Degraded() bool {
	return v.UnhealthyCount > 0 || v.RepairingCount > 0 || v.HealFailedCount > 0
}

// parseGuestNet extracts the `guest_net` stanza. A missing or malformed body yields Reported=false
// (unknown), never an error and never a partial claim of health.
func parseGuestNet(reportJSON string) guestNetView {
	v := guestNetView{Guests: []guestNetGuestView{}}
	if reportJSON == "" {
		return v
	}
	var body struct {
		GuestNet *struct {
			CheckedAt string `json:"checked_at"`
			Guests    []struct {
				VMID          int    `json:"vmid"`
				State         string `json:"state"`
				Mode          string `json:"mode"`
				IP            string `json:"ip"`
				HasRoute      bool   `json:"has_route"`
				DHClientAlive bool   `json:"dhclient_alive"`
				HealsLastHour int    `json:"heals_last_hour"`
				Healed        bool   `json:"healed"`
				HealSucceeded bool   `json:"heal_succeeded"`
				LastHealAt    string `json:"last_heal_at"`
				Damped        bool   `json:"damped"`
				Message       string `json:"message"`
			} `json:"guests"`
		} `json:"guest_net"`
	}
	// A decode error is NOT propagated: one box's bad field must not break the page for the rest of
	// the fleet. It degrades to Reported=false, which renders as unknown — the safe direction.
	if err := json.Unmarshal([]byte(reportJSON), &body); err != nil || body.GuestNet == nil {
		return v
	}
	v.Reported = true
	v.CheckedAt = body.GuestNet.CheckedAt
	for _, g := range body.GuestNet.Guests {
		gv := guestNetGuestView{
			VMID: g.VMID, State: g.State, Mode: g.Mode, IP: g.IP,
			HasRoute: g.HasRoute, DHClientAlive: g.DHClientAlive,
			RepairCount: g.HealsLastHour, LastHealAt: g.LastHealAt,
			Damped: g.Damped, Message: g.Message,
			Healed: g.Healed, HealSucceeded: g.HealSucceeded,
		}
		switch {
		case gv.Unknown():
			v.UnknownCount++
		case gv.State == "unhealthy" || gv.State == "static_fault":
			v.UnhealthyCount++
		}
		if gv.Repairing() {
			v.RepairingCount++
		}
		if gv.HealFailed() {
			v.HealFailedCount++
		}
		v.Guests = append(v.Guests, gv)
	}
	return v
}

// guestNet builds the card's view-model, applying the version gate before the report is consulted so
// that an old agent's silence is named as such rather than rendered as a finding.
func (s *Server) guestNet(host *store.Host, reportJSON string) guestNetView {
	if host.AgentVersion != "" && semver.Valid(host.AgentVersion) &&
		semver.Compare(host.AgentVersion, minAgentForGuestNet) < 0 {
		return guestNetView{Guests: []guestNetGuestView{}, AgentTooOld: true}
	}
	return parseGuestNet(reportJSON)
}

// storageTargetView is the rich per-drive row the host-detail Storage Targets table renders:
// fill %, role/state, thin-pool, and SMART health/temp/wear. Parsed from the latest report's
// storage_targets[] (the full hostStorageTarget wire shape lives in the api package; this view
// mirrors the fields the read-only page shows). Never carries a secret.
type storageTargetView struct {
	Name        string
	Type        string
	Role        string
	State       string
	MountPath   string
	Reachable   bool
	FillPct     float64 // used_fraction × 100
	HasThin     bool
	ThinDataPct float64
	// SMART (pointers → "n/a" when the drive/agent doesn't report the metric)
	SmartHealth string
	TempC       *int
	WearPct     *int // NVMe percentage_used
}

// parseHostStorageTargets extracts the rich storage-target rows from a report body. A
// missing/malformed body yields an empty slice.
func parseHostStorageTargets(reportJSON string) []storageTargetView {
	out := []storageTargetView{}
	if reportJSON == "" {
		return out
	}
	var body struct {
		StorageTargets []struct {
			Name         string  `json:"name"`
			Type         string  `json:"type"`
			Role         string  `json:"role"`
			State        string  `json:"state"`
			MountPath    string  `json:"mount_path"`
			Reachable    bool    `json:"reachable"`
			UsedFraction float64 `json:"used_fraction"`
			ThinPool     *struct {
				DataUsedFraction float64 `json:"data_used_fraction"`
			} `json:"thin_pool"`
			Smart struct {
				Health         string `json:"health"`
				TemperatureC   *int   `json:"temperature_c"`
				PercentageUsed *int   `json:"percentage_used"`
			} `json:"smart"`
		} `json:"storage_targets"`
	}
	if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
		return out
	}
	for _, t := range body.StorageTargets {
		v := storageTargetView{
			Name: t.Name, Type: t.Type, Role: t.Role, State: t.State,
			MountPath: t.MountPath, Reachable: t.Reachable,
			FillPct:     t.UsedFraction * 100,
			SmartHealth: t.Smart.Health, TempC: t.Smart.TemperatureC, WearPct: t.Smart.PercentageUsed,
		}
		if t.ThinPool != nil {
			v.HasThin = true
			v.ThinDataPct = t.ThinPool.DataUsedFraction * 100
		}
		out = append(out, v)
	}
	return out
}

// hostListRow is the per-host view model for the fleet list.
type hostListRow struct {
	HostID        string
	CustomerID    string
	CustomerName  string
	AgentVersion  string
	Status        string // ok | stale | down | pending
	StatusLabel   string
	StatusClass   string
	LastReportAt  *time.Time
	HasReport     bool
	GuestRunning  int
	GuestTotal    int
	Vitals        hostVitals
	WorstFillPct  float64
	WorstFillName string
	HasStorage    bool
	// FloorHeld (Part D): the managed controller-version floor is being WITHHELD because this box's
	// agent is below the current golden's MinAgent. HeldReason carries the operator-facing text.
	FloorHeld  bool
	HeldReason string
}

// customerName resolves a display name for a customer id (config first, then the last
// report's embedded name), falling back to the id. Read-only convenience for the Hosts views.
func (s *Server) customerName(customerID string) string {
	if cfg, _ := s.store.GetCustomerConfig(customerID); cfg != nil && cfg.CustomerName != "" {
		return cfg.CustomerName
	}
	if c, _ := s.store.GetCustomer(customerID); c != nil && c.CustomerName != "" {
		return c.CustomerName
	}
	return customerID
}

// handleHostsList renders the read-only fleet list of enrolled hosts (audit F-M1). GET only.
func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
	hosts, err := s.store.ListHosts()
	if err != nil {
		s.logger.Printf("[ERROR] Hosts list: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	// Worst storage fill per host, from every host's latest report.
	targets, _ := s.store.GetHostStorageTargets()
	worstFill := make(map[string]store.HostStorageTargetRow)
	for _, t := range targets {
		if cur, ok := worstFill[t.HostID]; !ok || t.Percent > cur.Percent {
			worstFill[t.HostID] = t
		}
	}

	rows := make([]hostListRow, 0, len(hosts))
	for _, h := range hosts {
		status := s.hostStatus(h.LastReportAt)
		row := hostListRow{
			HostID:       h.HostID,
			CustomerID:   h.CustomerID,
			CustomerName: s.customerName(h.CustomerID),
			AgentVersion: h.AgentVersion,
			Status:       status,
			StatusLabel:  hostStatusLabel(status),
			StatusClass:  hostStatusClass(status),
			LastReportAt: h.LastReportAt,
			HasReport:    h.LastReportAt != nil,
		}

		// Part D: surface a held managed floor (agent below the golden's MinAgent) so a held box is
		// never silently stale.
		if fd := s.store.ResolveManagedFloor(h.CustomerID); fd.Held {
			row.FloorHeld = true
			row.HeldReason = fd.HoldReason()
		}

		// Guest counts from the reality table (per-host accurate).
		guests, _ := s.store.ListGuestsForHost(h.HostID)
		row.GuestTotal = len(guests)
		for _, g := range guests {
			if g.Status == "running" {
				row.GuestRunning++
			}
		}

		// Vitals from the latest report body.
		if reportJSON, _ := s.store.GetLatestHostReportJSON(h.CustomerID); reportJSON != "" {
			row.Vitals = parseHostVitals(reportJSON)
		}

		if wf, ok := worstFill[h.HostID]; ok {
			row.HasStorage = true
			row.WorstFillPct = wf.Percent
			row.WorstFillName = wf.Name
		}

		rows = append(rows, row)
	}

	// R-21 slice C: the unclaimed-appliance section + bind picker.
	unclaimed, picker, err := s.gatherUnclaimed(time.Now())
	if err != nil {
		s.logger.Printf("[ERROR] Hosts list: unclaimed appliances: %v", err)
		// non-fatal: still render the host list
	}

	data := map[string]interface{}{
		"Hosts":          rows,
		"Unclaimed":      unclaimed,
		"CustomerPicker": picker,
		"Flash":          r.URL.Query().Get("flash"),
		"CSRFToken":      s.getCSRFToken(r),
	}
	if err := s.templates.ExecuteTemplate(w, "hosts.html", data); err != nil {
		s.logger.Printf("[ERROR] hosts.html template: %v", err)
	}
}

// hostDetailData assembles the view-model map the shared host_detail_body sub-template
// renders — used by BOTH the standalone /hosts/{id} page and the customer page's Host tab
// (v0.47.0). Booleans/counts only for DR/escrow; never api_key or blob contents.
// wrapperDrift compares the PBS-DR wrapper hash a host REPORTS against the one the operator vouched
// in the artifact manifest (R-50b(a), v0.68.0).
//
// The wrapper is a root-owned 0755 file installed from `raw/branch/main` — unversioned, unpinned and
// absent from every manifest until now, so two hosts installed a week apart could carry different
// privileged code while reporting the same agent version. This does not fix the delivery channel
// (R-50b(b)/(c)); it makes drift VISIBLE, which is what was missing.
//
// Returns ("", "") when either side is unknown: a hub that has not vouched a hash, or an agent below
// 0.91.0 that does not report one, is NOT drift — treating "unknown" as "mismatch" would light every
// host amber on the day this ships and teach the operator to ignore it.
func (s *Server) wrapperDrift(reportJSON string) (drift string, reported string) {
	reported = parseReportedWrapperSHA(reportJSON)
	return compareWrapperSHA(reported, s.store.GetArtifactManifest().WrapperSHA256), reported
}

// compareWrapperSHA is the pure comparison: "" (quiet) when either side is unknown, else ok/mismatch.
func compareWrapperSHA(reported, vouched string) string {
	if reported == "" || vouched == "" {
		return ""
	}
	if !strings.EqualFold(reported, vouched) {
		return "mismatch"
	}
	return "ok"
}

// parseReportedWrapperSHA pulls host.wrapper_sha256 out of a host report ("" when absent).
func parseReportedWrapperSHA(reportJSON string) string {
	if strings.TrimSpace(reportJSON) == "" {
		return ""
	}
	var doc struct {
		Host struct {
			WrapperSHA256 string `json:"wrapper_sha256"`
		} `json:"host"`
	}
	if json.Unmarshal([]byte(reportJSON), &doc) != nil {
		return ""
	}
	return strings.ToLower(strings.TrimSpace(doc.Host.WrapperSHA256))
}

func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]interface{} {
	status := s.hostStatus(host.LastReportAt)

	guests, _ := s.store.ListGuestsForHost(host.HostID)
	guestRunning := 0
	for _, g := range guests {
		if g.Status == "running" {
			guestRunning++
		}
	}

	reportJSON, _ := s.store.GetLatestHostReportJSON(host.CustomerID)
	vitals := parseHostVitals(reportJSON)
	wrapperDrift, reportedWrapperSHA := s.wrapperDrift(reportJSON)
	storageTargets := parseHostStorageTargets(reportJSON)
	sort.Slice(storageTargets, func(i, j int) bool { return storageTargets[i].Name < storageTargets[j].Name })
	// v0.51.0: capability chips — non-ok first (what the operator needs to see), then by name.
	capabilities := parseHostCapabilities(reportJSON)
	sort.SliceStable(capabilities, func(i, j int) bool {
		rank := func(s string) int {
			switch s {
			case "degraded":
				return 0
			case "inactive":
				return 1
			default:
				return 2
			}
		}
		if a, b := rank(capabilities[i].Status), rank(capabilities[j].Status); a != b {
			return a < b
		}
		return capabilities[i].Name < capabilities[j].Name
	})

	// DR / backup presence — booleans only, never the opaque blobs.
	drBundle, _ := s.store.GetHostDRBundle(host.HostID)
	escrow, _ := s.store.GetHostEscrow(host.HostID)

	// v0.85.0 Network — the host's addresses + its WireGuard allocation.
	network := s.hostNetwork(host, reportJSON)

	// R-319 Guest network — the R-54 watchdog's per-guest verdict AND its repair count.
	guestNet := s.guestNet(host, reportJSON)

	// v0.84.0 Console access — presence + username + set_at ONLY. GetHostRecoveryMeta cannot carry
	// the secret (its query does not select the column); the plaintext reaches the operator solely
	// through POST /hosts/{id}/reveal-recovery-credential.
	recoveryMeta, err := s.store.GetHostRecoveryMeta(host.HostID)
	if err != nil {
		s.logger.Printf("[ERROR] host recovery meta %s: %v", host.HostID, err)
	}

	return map[string]interface{}{
		"WrapperDrift":       wrapperDrift,
		"ReportedWrapperSHA": reportedWrapperSHA,
		"VouchedWrapperSHA":  s.store.GetArtifactManifest().WrapperSHA256,
		"HostID":             host.HostID,
		"CustomerID":         host.CustomerID,
		"CustomerName":       s.customerName(host.CustomerID),
		"AgentVersion":       host.AgentVersion,
		"CreatedAt":          host.CreatedAt,
		"Status":             status,
		"StatusLabel":        hostStatusLabel(status),
		"StatusClass":        hostStatusClass(status),
		"LastReportAt":       host.LastReportAt,
		"HasReport":          host.LastReportAt != nil,
		"RecoveryMode":       host.InRecoveryMode(time.Now()),
		"RecoveryUntil":      host.RecoveryModeUntil,
		"DesiredGeneration":  host.DesiredGeneration,
		"Vitals":             vitals,
		"Guests":             guests,
		"GuestRunning":       guestRunning,
		"GuestTotal":         len(guests),
		"StorageTargets":     storageTargets,
		"Capabilities":       capabilities,
		"NeedsDRMigration":   capabilitiesNeedDRMigration(capabilities),
		"DRPresent":          drBundle != nil,
		"EscrowPresent":      escrow != nil,
		// v0.60.0 Part B: retained superseded escrow blobs (data-first — old passphrases stay
		// R-recoverable). Operator-only surface.
		"SupersededEscrowCount": func() int { n, _ := s.store.CountSupersededEscrow(host.HostID); return n }(),
		// v0.84.0 break-glass Console access card. NEVER add a key holding the secret.
		// v0.85.0 Network card (addresses + WireGuard allocation/confirmation).
		"Network":         network,
		"GuestNet":        guestNet,
		"RecoveryVaulted": recoveryMeta != nil,
		"RecoveryUsername": func() string {
			if recoveryMeta != nil {
				return recoveryMeta.Username
			}
			return ""
		}(),
		"RecoverySetAt": func() time.Time {
			if recoveryMeta != nil {
				return recoveryMeta.SetAt
			}
			return time.Time{}
		}(),
		// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
		"LogBundles": s.hostLogBundleRows(host),
		"CSRFToken":  s.getCSRFToken(r),
		// v0.47.0 stale host removal: the danger-zone card renders ONLY for non-online
		// hosts — an ONLINE host is never deletable (no override exists).
		"Deletable": status != "ok",
	}
}

// handleHostDeleteImpact — GET /hosts/{id}/delete-impact (v0.47.0 stale host removal).
// The confirm dialog's impact probe: counts/booleans ONLY (never a secret, blob, or key),
// mirroring the global-floor impact endpoint's read-only-JSON pattern.
func (s *Server) handleHostDeleteImpact(w http.ResponseWriter, r *http.Request, hostID string) {
	host, err := s.store.GetHost(hostID)
	if err != nil {
		s.logger.Printf("[ERROR] host delete-impact %s: %v", hostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if host == nil {
		http.NotFound(w, r)
		return
	}
	a, err := s.store.CountHostArtifacts(hostID)
	if err != nil {
		s.logger.Printf("[ERROR] host delete-impact %s: artifacts: %v", hostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	status := s.hostStatus(host.LastReportAt)
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"status":             status,
		"deletable":          status != "ok",
		"guests":             a.Guests,
		"reports":            a.Reports,
		"log_bundles":        a.LogBundles,
		"escrow_present":     a.EscrowPresent,
		"wg_peer_bound":      a.WGPeerBound,
		"pbs_secret_present": a.PBSSecretPresent,
		"recovery_present":   a.RecoveryPresent,
	})
}

// handleHostRevealRecoveryCredential — POST /hosts/{id}/reveal-recovery-credential (v0.84.0).
// The operator-SESSION counterpart to the global-key API path (api/handler.go
// handleAdminGetRecoveryCredential), which stays untouched and remains the break-glass route for
// when this UI is itself unavailable — coupling it to the session layer would remove exactly the
// independence that makes it a fallback.
//
// POST, not GET, deliberately: it is the only way the ServeHTTP-level CSRF check applies, and a
// secret must not be retrievable by URL alone (prefetch, history, referrer).
//
// SECRET DISCIPLINE: the plaintext goes into the JSON response body and nowhere else — never the
// hub log, never the event message or details_json.
func (s *Server) handleHostRevealRecoveryCredential(w http.ResponseWriter, r *http.Request, hostID string) {
	host, err := s.store.GetHost(hostID)
	if err != nil {
		s.logger.Printf("[ERROR] reveal recovery credential %s: %v", hostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if host == nil {
		http.NotFound(w, r)
		return
	}
	cred, err := s.store.GetHostRecoveryCredential(hostID)
	if err != nil {
		s.logger.Printf("[ERROR] reveal recovery credential %s: %v", hostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cred == nil {
		// A 404 is not an access — nothing was delivered, so nothing is recorded on the timeline.
		s.logger.Printf("[INFO] reveal recovery credential %s: no credential vaulted", hostID)
		http.Error(w, "No recovery credential vaulted for this host", http.StatusNotFound)
		return
	}
	// Transparency by default, exactly as handleRequestLogTail does it: SaveEvent alone writes the
	// customer-visible timeline row WITHOUT emailing anyone (no dispatcher call here, by design).
	// An unbound host has no customer to tell — the [INFO] line below is then the only record.
	if host.CustomerID != "" {
		if _, err := s.store.SaveEvent(host.CustomerID, "recovery_credential_revealed", "info",
			"Az üzemeltető lekérte a géped konzolos hozzáférési jelszavát (távoli hibaelhárítás).", "", "hub"); err != nil {
			s.logger.Printf("[WARN] SaveEvent recovery_credential_revealed %s/%s: %v", host.CustomerID, hostID, err)
		}
	}
	s.logger.Printf("[INFO] operator revealed break-glass console credential for host %s (user=%s, secret %d chars)",
		hostID, cred.Username, len(cred.Secret))
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"host_id":  cred.HostID,
		"username": cred.Username,
		"password": cred.Secret,
		"set_at":   cred.SetAt.UTC().Format(time.RFC3339),
	})
}

// handleHostDelete — POST /hosts/{id}/delete (v0.47.0 stale host removal). Gates, in order:
//   - unknown host → 404
//   - ONLINE host → 409 unconditionally (host reports authenticate via GetHostByAPIKey;
//     deleting a live host permanently bricks its heartbeat channel — enroll is
//     passphrase-gated mint-once, so there is deliberately NO override)
//   - confirm_host_id mismatch → 400 (type-to-confirm)
//   - escrow present without delete_escrow=1 → 409 (store-enforced, fail-safe-to-refuse)
func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID string) {
	host, err := s.store.GetHost(hostID)
	if err != nil {
		s.logger.Printf("[ERROR] host delete %s: %v", hostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if host == nil {
		http.NotFound(w, r)
		return
	}
	if status := s.hostStatus(host.LastReportAt); status == "ok" {
		s.logger.Printf("[WARN] host delete refused: %s is online", hostID)
		http.Error(w, "Host is ONLINE — deletion is refused (a live agent would receive 401s permanently).", http.StatusConflict)
		return
	}
	if confirm := strings.TrimSpace(r.FormValue("confirm_host_id")); confirm != hostID {
		s.logger.Printf("[WARN] host delete refused: %s confirm mismatch", hostID)
		http.Error(w, "Confirmation does not match the host id — nothing deleted.", http.StatusBadRequest)
		return
	}
	deleteEscrow := r.FormValue("delete_escrow") == "1"
	if err := s.store.DeleteHost(hostID, deleteEscrow); err != nil {
		if errors.Is(err, store.ErrHostEscrowPresent) {
			s.logger.Printf("[WARN] host delete refused: %s has key escrow (acknowledgement missing)", hostID)
			http.Error(w, "This host has a key escrow (+ DR bundle). Tick the escrow acknowledgement to move it to retained custody — nothing deleted.", http.StatusConflict)
			return
		}
		s.logger.Printf("[ERROR] host delete %s: %v", hostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] host deleted: %s (escrow deleted: %v)", hostID, deleteEscrow)
	http.Redirect(w, r, "/hosts", http.StatusSeeOther)
}

// handleHostDetail renders the read-only per-host detail page (audit F-M1). GET only.
func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID string) {
	host, err := s.store.GetHost(hostID)
	if err != nil {
		s.logger.Printf("[ERROR] Host detail %s: %v", hostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if host == nil {
		http.NotFound(w, r)
		return
	}

	data := s.hostDetailData(host, r)
	if err := s.templates.ExecuteTemplate(w, "host_detail.html", data); err != nil {
		s.logger.Printf("[ERROR] host_detail.html template: %v", err)
	}
}
