package localapi

import (
	"context"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"

	"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
	"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
	"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)

// Disk management (slice 8C, doc 03 §6). The controller's disk-management UX stays in the
// controller; EXECUTION is the agent's. The security centerpiece: the agent decides
// data-bearing-ness by INSPECTING THE ACTUAL DEVICE (agent-internal evidence), never from the
// caller's claim — a compromised controller asserting "this drive is blank" cannot wipe a
// data-bearing drive. Benign ops (list/assign/eject/format-blank) execute self-serve; a
// data-bearing format is classified destructive → the gate refuses it `pending_signature` (the
// operator-signed completion is slice 10).

// DiskOps is the privileged host-storage surface the disk endpoints need. Satisfied by
// *storage.SudoHostOps. Optional — the endpoints report "not configured" when absent.
type DiskOps interface {
	EnsureMount(ctx context.Context, spec storage.MountSpec) error
	Unmount(ctx context.Context, where string) error
	Format(ctx context.Context, device, fstype string) error
	InspectDevice(ctx context.Context, device string) (storage.DeviceProbe, error)
	// ListCandidateDisks enumerates host disks the Impl-1 unclaimed filter proves are free to enroll
	// (Impl-2a discovery). Fail-safe: a device not provably unclaimed is omitted.
	ListCandidateDisks(ctx context.Context) ([]storage.CandidateDisk, error)
}

// StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the
// reversibility gate, TIERED by the agent's authoritative device-role verdict. Satisfied by an
// adapter over reconcile.Gate in main.go.
//   - user-data  → customer-confirmable: allowed iff the request carries the customer's confirmation
//     bound to the device's durable id (no operator signature).
//   - system/backup → operator-signature only (unsigned → pending_signature; Confirmed is ignored).
type StorageGate interface {
	AuthorizeWipe(req WipeRequest) WipeDecision
}

// WipeRequest is the inspection-derived input to the wipe gate. Role + DeviceDurableID are
// AGENT-INTERNAL (the agent classified the role and re-resolved the durable id); Confirmed +
// ConfirmDurableID are the caller's claim, honored ONLY for user-data and ONLY on a durable-id match.
type WipeRequest struct {
	Role             string // "system" | "backup" | "user-data" (agent-classified)
	DeviceDurableID  string // agent-re-resolved durable id of the device ("" if unresolvable)
	Confirmed        bool
	ConfirmDurableID string
}

// WipeDecision is the gate's verdict.
type WipeDecision struct {
	Allowed           bool
	Tier              string // "customer_confirmable" | "destructive"
	Reason            string // machine reason (audit/UI)
	NeedsConfirmation bool   // user-data, not-yet-confirmed → ask the customer (NOT a signature)
}

// GuestLister lists the host's guests (to map a mount to the guests that depend on it for the
// eject warning). Satisfied by *proxmox.Client.
type GuestLister interface {
	ListLXC(ctx context.Context) ([]proxmox.Guest, error)
}

// GuestAttacher binds an enrolled user-data drive's felhom-data namespace into a guest as an RW bind
// mount (slice 10 P2, Model A) and reboots the guest to activate persisted-but-inactive binds (the
// host-side live inject is blocked on unprivileged guests). Satisfied by *GuestBinder.
type GuestAttacher interface {
	// AttachDrive (intermediary model) binds the drive's felhom-data under the shared parent so it
	// appears live in the guest at the returned stable path — no pct, no reboot. `where` = raw /mnt/<name>.
	// vmid is needed to verify GUEST visibility (a guest reboot needs a fresh re-bind to re-propagate).
	AttachDrive(ctx context.Context, vmid int, where string) (guestPath string, err error)
	// DetachDrive unmounts the drive's felhom-data from the shared parent (live, fail-closed).
	DetachDrive(ctx context.Context, where string) error
	// EnsureSharedParent makes the host stable parent shared + installs the boot-persistence unit.
	EnsureSharedParent(ctx context.Context) error
	// GuestSeesMount reports whether vmid's guest sees `path` as a mount in its own namespace — the
	// guest-VISIBILITY signal, distinct from the host having the bind. One of the three terms behind
	// BoundUnderParent. It is a path-presence test and NOT a liveness signal (R-117): a stale bind over a
	// dead device is still "seen". Liveness is bindLiveness's job.
	GuestSeesMount(ctx context.Context, vmid int, path string) bool
	// GuestBootID returns a token that changes on every guest boot (host or guest) but is stable across a
	// controller-only restart — the deterministic guest-reboot signal the controller recreates apps on.
	GuestBootID(ctx context.Context, vmid int) string
	// AttachBind is the LEGACY per-drive `pct set -mpN` bind (pre-intermediary). Retained for the
	// transition; new attaches use AttachDrive.
	AttachBind(ctx context.Context, vmid int, mountKey, where string) error
	// DetachBind removes a LEGACY mountpoint slot from the guest config (the decommission C1 fix — a
	// removed bind can't brick the next boot with a missing source). Runs on the running guest.
	DetachBind(ctx context.Context, vmid int, mountKey string) error
	RebootGuest(ctx context.Context, vmid int) error
}

// IntentRecorder persists drive enroll/eject/decommission INTENT (slice 10 P3 self-heal), keyed by
// durable-id, so the watchdog reconciles only enrolled drives and respects an official eject /
// permanent decommission. Get lets the startup re-assert be intent-aware (B2 — skip non-enrolled).
// Satisfied by *storage.IntentStore. Optional — when nil, the local API records no intent (self-heal
// is ungated and the re-assert falls back to legacy bind-all behavior).
type IntentRecorder interface {
	SetEnrolled(durableID string) error
	SetEjected(durableID string) error
	SetDecommissioned(durableID string) error
	Get(durableID string) storage.DriveIntent
}

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

// DiskInfo is one host drive with its data-bearing flag (for the UI).
type DiskInfo struct {
	Name          string `json:"name"`           // PVE storage id
	Type          string `json:"type"`           // local-dir | usb | lvmthin | …
	State         string `json:"state"`          // attached | disconnected
	BackingDevice string `json:"backing_device"` // /dev/sdb1, … ("" for network/lvm)
	MountPath     string `json:"mount_path"`
	Class         string `json:"class"` // fast | slow | ""
	// Role is the agent's AUTHORITATIVE protection tier (system | backup | user-data), derived from
	// the agent's own storage view + host topology — never from the controller. The controller drives
	// the UI from it: system/backup get a lock badge and NO destructive controls; user-data is
	// customer-manageable. Defense in depth — the agent re-enforces role at wipe time regardless.
	Role        string `json:"role"`
	DataBearing bool   `json:"data_bearing"` // agent device-inspection verdict (UI hint)
	DataReason  string `json:"data_reason,omitempty"`
	// Capacity (from the agent's storage view) — for the controller's capacity bar. 0 when unknown.
	TotalBytes   int64   `json:"total_bytes"`
	UsedBytes    int64   `json:"used_bytes"`
	UsedFraction float64 `json:"used_fraction"`
	// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
	// controller strips the "uuid:" prefix to get the fs UUID it passes to POST /disks/assign —
	// the only way the (de-privileged) controller can learn the mount key it cannot read itself.
	DurableID string `json:"durable_id,omitempty"`
	// WipeDurableID is the device's WIPE-binding durable id in the SAME scheme the format gate resolves
	// against (byid:<wwn>/byuuid:<uuid>). F20-BUG2: the customer must confirm a data-bearing wipe with
	// THIS id, not DurableID (uuid:) — passing the uuid: id was rejected as a binding_mismatch. "" when
	// the device has no durable identity (a wipe of it can't be bound anyway). Distinct from DurableID
	// (uuid:, used for /disks/assign) on purpose; both are derived from the agent's own device read.
	WipeDurableID string `json:"wipe_durable_id,omitempty"`
	// GuestAttached reports whether THIS guest (the token's vmid) actually has the drive's felhom-data
	// namespace bound into its config — i.e. the drive is usable IN the guest, not merely present on the
	// host. F9: host presence (State=attached) != guest-usable; this is the missing signal that made the
	// HDD look available when it wasn't bound. Only meaningful for user-data drives. LEGACY (per-drive
	// `pct set -mpN` model) — the intermediary model uses BoundUnderParent.
	GuestAttached bool `json:"guest_attached"`
	// BackupTarget (E-2) reports that this drive backs the PRIMARY whole-guest backup tier. Additive:
	// an older controller ignores it. It is the agent's answer, not the controller's intent flag —
	// on a hand-migrated box (E-1) intent is unset while the drive really is the target.
	BackupTarget bool `json:"backup_target,omitempty"`
	// GuestPath is the drive's STABLE in-guest path in the intermediary-mount model
	// (/mnt/felhom-drives/<name>). This is what the controller repoints HDD_PATH to and registers as the
	// storage path. Set for /mnt/<name> drives; "" otherwise. Distinct from MountPath (the RAW host PVE
	// mount the agent ops on).
	GuestPath string `json:"guest_path,omitempty"`
	// BoundUnderParent reports whether the drive is live + usable in the guest in the intermediary model.
	// The controller's drive-absent gate + auto-restart key on this (and State).
	//
	// It is a CONJUNCTION of THREE facts, and all three are load-bearing:
	//   1. felhom-data is bound under the shared parent at GuestPath (the guest-visible mount check), and
	//   2. the drive's RAW host mount is still mounted — i.e. the DEVICE is still there (R-113), and
	//   3. the bind actually WORKS: it names the same device as the raw mount, and that filesystem has
	//      not aborted (R-117, v0.117.0 — see bindLiveness).
	// Half 1 alone was the bug R-113 fixed: the raw mount is device-bound and dies with its device, but
	// the agent's own bind is not, so half 1 stays true over a stale shell after the device is pulled. The
	// controller read that survivor as "present" and the drive-absent alarm could never fire — measured
	// live in E-2d (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.2). Half 2 alone would regress boot
	// ordering, where the raw mounts early and the bind lands ~18s later; the conjunction keeps that
	// window reading absent.
	//
	// Terms 1 and 2 TOGETHER were still not liveness, which is R-117: both are path-presence tests
	// comparing only field 5 of a mountinfo line, so both stay true over a bind that names the drive that
	// went away while the raw mount healed onto the returning one via its fs-UUID-keyed unit. Measured
	// live: raw on 8:32 /dev/sdc, bind on 8:16 /dev/sdb with `shutdown`, this field TRUE, EIO on every
	// read and write, and the gate restarting the customer's apps onto it with no alarm on any channel
	// (felhom.eu audits/SPIKE-r117-bind-liveness-2026-07-30.md §5.2).
	//
	// THE TESTS THAT PIN THIS COMMENT, because for three releases it promised a property nothing tested
	// (spike §5.3): disks_bind_liveness_test.go — TestDisks_BindLiveness_StaleBindReadsAbsent (term 3,
	// case a), _AbortedFilesystemReadsAbsent (term 3, case b, the steady-state case that emits nothing
	// today), _UnknownIsTreatedAsPresent (the cannot-tell rule) and _HealthyReadsPresent (no false
	// negative). Each asserts the CONSEQUENCE — what this field reads — not the mechanism.
	BoundUnderParent bool `json:"bound_under_parent"`
	// Smart is the already-computed per-disk SMART health summary (v0.94.0), serialized here so the
	// controller can render a disk-health card + degradation alert WITHOUT any new smartctl load — the
	// value is copied straight from the target's Observe-time enrichment. omitempty + a pointer so a
	// device that exposes no SMART (USB bridge, unread) is ABSENT, not a misleading zero-value UNKNOWN;
	// the controller feature-detects presence and renders "Nincs adat" when nil (never alarms).
	Smart *hub.SmartSummary `json:"smart,omitempty"`
}

// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	targets, err := s.storage.Observe(r.Context())
	if err != nil {
		writeErr(w, http.StatusBadGateway, "could not read storage view")
		return
	}
	// Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative
	// (the agent's own mount/topology read, never the caller's claim).
	sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
	// F9: which host mount paths are actually BOUND into THIS guest's config (guest-usable, not just
	// host-present). A bind's mp= equals the guest path, which is the drive's host mount path (`where`).
	boundPaths := s.guestBoundPaths(r.Context(), vmid)
	// E-2: the PRIMARY tier's storage id — the whole-guest vzdump destination. "" when no tier
	// carries a target (the legacy single-tier shape), which correctly flags nothing.
	primaryTargetID := s.primaryTier().TargetID
	out := make([]DiskInfo, 0, len(targets))
	for _, t := range targets {
		di := DiskInfo{
			Name: t.Name, Type: t.Type, State: t.State,
			BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint,
			DurableID:     t.DurableID,
			Role:          string(storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)),
			TotalBytes:    t.TotalBytes,
			UsedBytes:     t.UsedBytes,
			UsedFraction:  t.UsedFraction,
			GuestAttached: t.MountPath != "" && boundPaths[t.MountPath],
			// E-2: is THIS drive the whole-guest backup target? The agent is the only component that
			// can answer — the controller's own StoragePath.BackupTarget is customer INTENT, and on a
			// box migrated by hand (E-1) nobody ever assigned it, so intent is empty while the drive
			// really is the target. Reported here so the controller can name the drive in an
			// absent-target alarm and hide the destructive controls the agent would refuse anyway.
			BackupTarget: t.Name == primaryTargetID,
		}
		// Intermediary model: the stable in-guest path + whether felhom-data is bound under the parent.
		// Only user-data /mnt/<name> drives have a guest path (system/backup mounts never cross in).
		if di.Role == string(storage.RoleUserData) {
			if gp := StablePathForRaw(t.MountPath); gp != "" {
				di.GuestPath = gp
				// R-113: AND in device presence. A conjunction, deliberately — it leaves the
				// boot-ordering behaviour the controller's gate depends on exactly as it was
				// (raw mounted early, bind not yet ⇒ still absent) while closing the case the
				// gate could never see (bind outlived the device ⇒ now absent).
				// R-117: AND in bind LIVENESS. The two terms above are both path-presence tests, so
				// both stay true over a bind that names the drive that went away while the raw mount
				// healed onto the returning one — EIO on every call, payload healthy.
				di.BoundUnderParent = s.boundUnderParent(r.Context(), vmid, gp) &&
					s.devicePresent(t.MountPath) &&
					s.bindUsable(gp, t.MountPath)
			}
		}
		// R-116: carry the GUEST PATH on the backup-target row even when its role has flipped to
		// system — but ONLY when that flip was caused by the device vanishing.
		//
		// WHY. The controller keys the drive-absent alarm on the registered StoragePath, which for an
		// external drive is the GUEST path. When the device goes, Observe's exactMountDevice fails, so
		// t.BackingDevice becomes "" and RoleForStorage returns RoleSystem (role.go:180-181) — the block
		// above is skipped and this row loses its guest path. It keeps its MountPath, so the union loop
		// below DEDUPES the registry row away (`seen[d.MountPath]`), and /disks ends up carrying NO row
		// with that guest path at all. driveTargetByPath then has no entry, isTarget[guestPath] is a
		// missing key, and the specific backup_target_absent alarm cannot fire — the generic one goes
		// out instead, while the RETURN (rows rejoined) fires the specific recovery. An unmatchable
		// pair. Measured live: felhom.eu audits/SESSION-C-2026-07-29.md §5.
		//
		// THE GATES, each load-bearing:
		//   di.GuestPath == ""   — never touch the user-data path above; this is a fallback, not a rule.
		//   di.BackupTarget      — only the target row. No other system/backup mount gains a guest path,
		//                          so the boundary at :213-214 stands: this is not "system mounts now
		//                          cross into the guest", it is "the drive the alarm is about keeps its
		//                          identity while it is missing".
		//   t.BackingDevice == "" — ONLY the vanished-device flip. A storage that is RoleSystem because
		//                          it is genuinely system-BACKED has a non-empty BackingDevice and is
		//                          excluded. Without this gate a dir storage at /mnt/<name> living on the
		//                          root disk would acquire a guest path.
		//
		// Case B (the COMMON fresh-box shape) is safe twice over: the target is the builtin `local` on
		// /var/lib/vz, and StablePathForRaw returns "" for anything that is not exactly /mnt/<name>
		// (DriveNameFromRaw, intermediary.go:79-88), so nothing is set even before the gates apply.
		//
		// This cannot make the gate read an absent drive as PRESENT: BoundUnderParent is assigned only
		// inside the two guest-path blocks a system-role row never enters, so it stays false, and
		// planDriveGates computes present[gp] = present[gp] || d.BoundUnderParent. Inert by construction
		// — pinned by TestAbsentTargetRowDoesNotRegisterPresence.
		//
		// v0.116.0 — WHY v0.115.0 (the MountPath-only form) WAS INERT, measured not reasoned. In the
		// absent state t.MountPath is ALSO "" — the same exactMount failure that emptied BackingDevice
		// empties it — so StablePathForRaw("") returned "" and this assigned nothing. Captured payload:
		// felhom.eu audits/DIAG-r116-disks-payload-2026-07-30.md §6.2.
		//
		// t.ConfigPath is the fix: the storage's CONFIGURED path from storage.cfg, which is configuration
		// and therefore survives the device. MountPath is tried FIRST so the present-state path and
		// v0.115.0's tested behaviour are byte-identical; ConfigPath is consulted only when the mount is
		// genuinely gone. MountPath is deliberately NOT back-filled from ConfigPath — see the union-dedup
		// note below for the consumer that would break, and because a path that is not mounted is not a
		// "host mountpoint" (this field's own contract, :152-153).
		if di.GuestPath == "" && di.BackupTarget && t.BackingDevice == "" {
			if gp := StablePathForRaw(t.MountPath); gp != "" {
				di.GuestPath = gp
			} else {
				di.GuestPath = StablePathForRaw(t.ConfigPath)
			}
		}
		// Inspect the backing device for the UI's data-bearing hint (the authoritative check
		// is re-run at format time on the actual device).
		if t.BackingDevice != "" {
			if probe, perr := s.disks.InspectDevice(r.Context(), t.BackingDevice); perr == nil {
				di.DataBearing = probe.DataBearing()
				di.DataReason = probe.Reason()
			} else {
				di.DataBearing = true // fail-safe
				di.DataReason = "could not inspect device"
			}
			// F20-BUG2: surface the gate-scheme wipe id (byid:/byuuid:) so a customer-confirmed wipe
			// binds with the id the gate accepts. Same seam the gate uses → guaranteed to match.
			if wid, werr := s.deviceDurableID(t.BackingDevice); werr == nil {
				di.WipeDurableID = wid
			}
		}
		// v0.94.0: surface the already-computed SMART only when it was actually read (Health set).
		// A zero-value summary (enrich skipped / no smartctl device) has Health "" → stays omitted, so
		// the controller sees "absent" and renders "Nincs adat" rather than a false UNKNOWN.
		if t.Smart.Health != "" {
			sm := t.Smart
			di.Smart = &sm
		}
		out = append(out, di)
	}
	// Impl-2a: union in registry+units drives that Observe() does NOT surface (a drive with no PVE
	// dir-storage). Additive + deduped by mount path — an existing drive already shown via Observe is
	// NOT duplicated, and no Observe row is dropped (so this can never regress the current view).
	if s.driveTargets != nil {
		seen := make(map[string]bool, len(out))
		// R-116: dedup ALSO by guest path. `seen` keys on MountPath, the one field the absent state
		// empties, so with the device gone /mnt/<name> is absent from `seen` and the registry row was NOT
		// skipped — /disks carried the drive TWICE, the Observe row holding BackupTarget with no key and
		// the registry row holding both keys with BackupTarget defaulted false. driveTargetByPath
		// (controller intermediary.go:602-618) assigns rather than ORs, and the registry row is appended
		// LAST, so its false won on both keys. Measured, 4 rows vs 3:
		// felhom.eu audits/DIAG-r116-disks-payload-2026-07-30.md §6.2.
		//
		// THE JOIN, and it is the whole point: with the device gone the two records of one drive share NO
		// runtime field — no mount, no backing device, and the Observe row's DurableID has degraded off the
		// fs-UUID. What they DO share is CONFIGURATION: the Observe row's storage path (storage.cfg) and the
		// registry row's unit `Where` (the .mount unit) are the same path, so both derive the same stable
		// guest path. That is the key both sides can still compute, which is why the dedup keys on it.
		seenGuest := make(map[string]bool, len(out))
		for _, d := range out {
			if d.MountPath != "" {
				seen[d.MountPath] = true
			}
			if d.GuestPath != "" {
				seenGuest[d.GuestPath] = true
			}
		}
		if drives, derr := s.driveTargets.Known(r.Context()); derr == nil {
			for _, d := range drives {
				if d.MountPath == "" || seen[d.MountPath] {
					continue
				}
				// Same drive as an Observe row that already carries this guest path — skip it. Suppressing
				// it rather than teaching it BackupTarget is deliberate: the registry row has a non-empty
				// MountPath (from the unit file, stale by then), and the controller reads
				// `d.BackupTarget && d.MountPath != ""` as "a real drive with its own mountpoint — HEALTHY"
				// (backup_target_offer.go:79). Putting the flag on a row with a stale MountPath would have
				// silently regressed R-114, telling the customer the backup target is fine while its drive
				// is missing. Pinned by TestAbsentTargetKeepsR114DegradedSignal.
				if gp := StablePathForRaw(d.MountPath); gp != "" && seenGuest[gp] {
					continue
				}
				di := DiskInfo{
					Name: d.Name, Type: d.Type, State: "attached",
					MountPath: d.MountPath, DurableID: d.DurableID,
					Role:          string(storage.RoleUserData),
					GuestAttached: boundPaths[d.MountPath],
				}
				// Intermediary model: report the stable in-guest path + whether felhom-data is bound live,
				// exactly like the Observe path — else the controller reads a registry drive as "Leválasztva".
				if gp := StablePathForRaw(d.MountPath); gp != "" {
					di.GuestPath = gp
					// R-113 + R-117: same three-term conjunction as the Observe path. This path matters
					// MORE, not less — a registry drive with no PVE dir-storage is exactly the shape
					// E-2d detached, and its State is hardcoded "attached" below, so these checks are
					// the only device truth this row carries.
					di.BoundUnderParent = s.boundUnderParent(r.Context(), vmid, gp) &&
						s.devicePresent(d.MountPath) &&
						s.bindUsable(gp, d.MountPath)
				}
				// A registry drive has no PVE `pvesm status` snapshot, so fill backing device + capacity
				// from the host directly: resolve the device by fs-UUID, and statfs the mount for size —
				// else the agent-view shows "—" for the device and no size bar.
				if d.UUID != "" {
					// Resolve to the real /dev node (e.g. /dev/sdd), not the by-uuid symlink path, to match
					// how Observe-sourced rows display the backing device.
					if dev, err := s.resolveStorageDevice("uuid:" + d.UUID); err == nil {
						di.BackingDevice = dev
					}
				}
				// Fix B (v0.95.0): union-path drives skip Observe's enrich, so read SMART here through the
				// same seam the dir targets use. Only set when the read actually ran (Health != "").
				if di.BackingDevice != "" && s.smart != nil {
					if sm := s.smart.SMARTForBacking(r.Context(), di.BackingDevice); sm.Health != "" {
						di.Smart = &sm
					}
				}
				if total, used, okc := statfsCapacity(d.MountPath); okc {
					di.TotalBytes, di.UsedBytes = total, used
					di.UsedFraction = float64(used) / float64(total)
				}
				out = append(out, di)
			}
		} else {
			s.logger.Warn("disks: registry drive union skipped", "err", derr)
		}
	}

	// Guest boot-id (intermediary model): changes on every guest boot, stable across controller restarts.
	// The controller persists it and deterministically recreates drive-backed apps when it changes.
	bootID := ""
	if s.guestAttach != nil {
		bootID = s.guestAttach.GuestBootID(r.Context(), vmid)
	}
	writeOK(w, map[string]any{"vmid": vmid, "disks": out, "guest_boot_id": bootID})
}

// handleDiskCandidates (Impl-2a) lists the host disks FREE for Felhom to enroll — the raw-device
// discovery the enrollment wizard (Impl-2b) consumes. Read-only + benign (the Impl-1 filter never
// offers a claimed/OS disk). Response splits into `initialize` (all unclaimed) and `attach` (the subset
// already carrying a mountable FS). GET /disks/candidates.
func (s *Server) handleDiskCandidates(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	cands, err := s.disks.ListCandidateDisks(r.Context())
	if err != nil {
		writeErr(w, http.StatusBadGateway, "could not scan host disks")
		return
	}
	initialize := make([]storage.CandidateDisk, 0, len(cands))
	attach := make([]storage.CandidateDisk, 0)
	for _, c := range cands {
		initialize = append(initialize, c) // every unclaimed disk can be initialized (format → enroll)
		if c.Mountable {
			attach = append(attach, c) // already has a mountable FS → attach without formatting
		}
	}
	writeOK(w, map[string]any{"vmid": vmid, "initialize": initialize, "attach": attach})
}

type assignRequest struct {
	VMID    int    `json:"vmid"`
	UUID    string `json:"uuid"`
	Where   string `json:"where"`
	FSType  string `json:"fstype"`
	Options string `json:"options"`
}

// handleDiskAssign attaches a drive as a host mount (benign, additive → EnsureMount). Self-serve.
func (s *Server) handleDiskAssign(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	var req assignRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	// EnsureMount validates uuid/where/fstype/options itself (storage/validate.go).
	if err := s.disks.EnsureMount(r.Context(), storage.MountSpec{
		Name: req.UUID, UUID: req.UUID, Where: req.Where, FSType: req.FSType, Options: req.Options,
	}); err != nil {
		s.logger.Error("local-api: disk assign", "vmid", vmid, "where", req.Where, "err", err)
		writeErr(w, http.StatusBadRequest, "assign failed: "+err.Error())
		return
	}
	s.logger.Info("local-api: disk assigned (host mount ensured)", "vmid", vmid, "where", req.Where)
	writeOK(w, map[string]any{"vmid": vmid, "assigned": req.Where})
}

type ejectRequest struct {
	VMID  int    `json:"vmid"`
	Where string `json:"where"`
}

// handleDiskEject safe-unmounts a host mount (benign — data preserved, re-attachable) and returns
// the guests that depend on it so the controller can warn which apps lose that storage.
func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	var req ejectRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	if strings.TrimSpace(req.Where) == "" {
		writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
		return
	}
	// ROLE GATE (defense in depth): eject is permitted ONLY for a user-data mount. The agent
	// classifies the role of the storage at `where` from its OWN view — never the caller's claim —
	// and refuses system/backup. The UI hiding the button is NOT the control: a direct API call (or a
	// compromised controller) trying to unmount /var/lib/vz or the PBS mount is refused here. Fails
	// SAFE: an unresolvable mount → protected → refused (most-protected-on-ambiguity, like the wipe).
	if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
		s.logger.Warn("local-api: protected — eject refused by role",
			"vmid", vmid, "where", req.Where, "role", role)
		writeErr(w, http.StatusForbidden, "mount is system/backup-protected — eject refused (role: "+string(role)+")")
		return
	}
	// E-2c: the role gate above passes a drive that is BOTH user-data and the vzdump target (E-1 put
	// the target on the enrolled drive's own mountpoint). Refuse specifically, naming the remedy.
	if s.refuseIfBackupTarget(r.Context(), w, "eject", vmid, req.Where) {
		return
	}
	dependents := s.dependentGuests(r.Context(), req.Where)
	// Record the EJECT intent BEFORE unmounting (the target still resolves to its durable-id) so the
	// self-heal watchdog leaves it alone — an OFFICIAL eject is the only thing that sets this (P3); an
	// out-of-band unmount records nothing and is healed.
	s.recordIntent(r.Context(), req.Where, "ejected")
	// Intermediary model: eject = DETACH the felhom-data bind from under the shared parent (live, fail-
	// closed in the guest). The RAW /mnt/<name> host mount is LEFT MOUNTED so a reconnect re-binds it
	// cleanly (a non-removable drive isn't re-plugged); a raw unmount here would orphan it. Physical
	// removal is the separate "remove from system" action. (Consistent with decommission.)
	if s.guestAttach != nil {
		if err := s.guestAttach.DetachDrive(r.Context(), req.Where); err != nil {
			s.logger.Error("local-api: eject guest-detach (intermediary)", "vmid", vmid, "where", req.Where, "err", err)
			writeErr(w, http.StatusBadRequest, "eject failed: "+err.Error())
			return
		}
	}
	s.logger.Info("local-api: drive ejected (bind detached, raw mount kept)",
		"vmid", vmid, "where", req.Where, "dependent_guests", len(dependents))
	writeOK(w, map[string]any{"vmid": vmid, "ejected": req.Where, "dependent_guests": dependents})
}

// handleDiskDecommission is the SELF-SERVE, NON-DESTRUCTIVE permanent removal of a user-data drive
// (B2). It mirrors handleDiskEject EXACTLY — withGuest self-scoping, scopedFromBody, and the same
// user-data ROLE GATE (a system/backup mount is refused 403; fail-safe-to-protected on ambiguity) so
// a compromised controller can't decommission protected storage. Unlike the operator-signed
// DecommissionExecutor it needs no signature: it is the customer's own drive. It records a PERMANENT
// decommission intent (the self-heal watchdog + the intent-aware re-assert never auto-mount/re-bind it
// again), prunes the guest-bind record (hygiene), and unmounts so the drive is physically removable.
// It NEVER calls any format/mkfs path — the data stays on the drive.
func (s *Server) handleDiskDecommission(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	var req ejectRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	if strings.TrimSpace(req.Where) == "" {
		writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
		return
	}
	// ROLE GATE (same as eject): user-data only. The agent classifies from its OWN view, never the
	// caller's claim; an unresolvable mount fails safe to protected → refused.
	if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
		s.logger.Warn("local-api: protected — decommission refused by role",
			"vmid", vmid, "where", req.Where, "role", role)
		writeErr(w, http.StatusForbidden, "mount is system/backup-protected — decommission refused (role: "+string(role)+")")
		return
	}
	// E-2c: same narrow gate as eject — decommission migrates data off and retires the drive, which
	// would strand the backup target just as thoroughly.
	if s.refuseIfBackupTarget(r.Context(), w, "decommission", vmid, req.Where) {
		return
	}
	dependents := s.dependentGuests(r.Context(), req.Where)
	// Resolve the durable-id BEFORE unmounting (it still resolves while mounted) for the bind prune.
	id := s.durableIDForMount(r.Context(), req.Where)
	// Record the PERMANENT decommission intent first (self-heal never re-mounts it again).
	s.recordIntent(r.Context(), req.Where, "decommissioned")
	// Hygiene: drop the guest-bind record so the startup re-assert carries no stale id.
	if id != "" && s.guestBinds != nil {
		if err := s.guestBinds.Remove(vmid, id); err != nil {
			s.logger.Warn("local-api: guest-bind remove failed", "vmid", vmid, "durable_id", id, "err", err)
		}
	}
	// C1 FIX (B3 critical bug): delete the guest mountpoint bind for this drive so its now-missing
	// source can't brick the guest on the NEXT reboot. The old decommission unmounted but left the
	// `mpN` in the config → pre-start mount failure → all apps down. Runs on the running guest (config
	// edit, no start lock → no deadlock). Best-effort: a missing slot is already clean.
	if s.guestAttach != nil {
		// Intermediary model: unmount the felhom-data bind from under the shared parent (live, no reboot).
		if err := s.guestAttach.DetachDrive(r.Context(), req.Where); err != nil {
			s.logger.Warn("local-api: guest-detach (intermediary) failed", "vmid", vmid, "where", req.Where, "err", err)
		}
		// Legacy per-drive model: delete any lingering `mpN` slot (no-op post-migration; C1 fix otherwise).
		if slot := s.guestSlotForPath(r.Context(), vmid, req.Where); slot != "" {
			if err := s.guestAttach.DetachBind(r.Context(), vmid, slot); err != nil {
				s.logger.Warn("local-api: guest-detach (legacy mp) failed — mp left in config",
					"vmid", vmid, "slot", slot, "where", req.Where, "err", err)
			}
		}
	}
	// Intermediary model: decommission is a LOGICAL retire (data stays, re-enrollable). It DetachDrive'd
	// the bind under the parent above (the drive is no longer visible to the guest); it does NOT unmount
	// the RAW /mnt/<name> host mount — that would orphan the drive (a non-removable SATA drive doesn't get
	// re-plugged), so a one-click re-enroll (H3) could not re-bind it. The soft decommission marker blocks
	// scheduling; physical removal is the separate "remove from system" action. NEVER format/mkfs here.
	s.logger.Info("local-api: drive decommissioned (logical retire, data untouched)",
		"vmid", vmid, "where", req.Where, "durable_id", id, "dependent_guests", len(dependents))
	writeOK(w, map[string]any{"vmid": vmid, "decommissioned": req.Where, "dependent_guests": dependents})
}

type guestAttachRequest struct {
	VMID  int    `json:"vmid"`
	Where string `json:"where"` // the host mount path of the enrolled drive (e.g. /mnt/felhom-usb)
}

// handleDiskGuestAttach binds an enrolled user-data drive's felhom-data namespace into THIS guest as
// an RW bind mount (slice 10 P2, Model A). Self-scoped (the vmid is the token's). Idempotent: if a
// mountpoint already binds `where`, it returns the existing slot without re-attaching. The drive must
// already be mounted on the host at `where` (the enroll flow's assign did that) — this only adds the
// guest passthrough. The customer's non-felhom data on the drive is NOT exposed (only felhom-data).
func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.guestAttach == nil {
		writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
		return
	}
	var req guestAttachRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	where := strings.TrimSpace(req.Where)
	if !validGuestMountPath(where) {
		writeErr(w, http.StatusBadRequest, "where must be an absolute /mnt/<name> path (no traversal)")
		return
	}
	// Intermediary model: ensure the host shared parent, then bind the drive's felhom-data UNDER it. It
	// propagates into the running guest LIVE — no pct, no slot, no reboot. AttachDrive is idempotent (a
	// no-op when the stable path is already a mountpoint), so this is the re-enroll path too.
	if err := s.guestAttach.EnsureSharedParent(r.Context()); err != nil {
		s.logger.Warn("local-api: guest-attach — shared parent ensure failed (continuing)", "vmid", vmid, "err", err)
	}
	stable, err := s.guestAttach.AttachDrive(r.Context(), vmid, where)
	if err != nil {
		s.logger.Error("local-api: guest-attach (intermediary)", "vmid", vmid, "where", where, "err", err)
		writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
		return
	}
	// Record the drive as ENROLLED so the self-heal reconcile keeps it bound (P3), and persist the
	// per-guest bind so the startup reconcile restores it after a re-provision (F9 — now host-side only).
	s.recordIntent(r.Context(), where, "enrolled")
	s.recordGuestBind(r.Context(), vmid, where)
	s.logger.Info("local-api: guest-attach bound under shared parent", "vmid", vmid, "where", where, "guest_path", stable)
	writeOK(w, map[string]any{"vmid": vmid, "attached": where, "guest_path": stable})
}

type guestRebootRequest struct {
	VMID int `json:"vmid"`
}

// handleGuestReboot reboots THIS guest (self-scoped) to activate persisted-but-inactive mountpoint
// binds (slice 10 P2 activation). It runs the reboot DETACHED and returns 202 immediately, so the
// calling controller gets a clean response before the reboot takes it (and the agent — host-side —
// survives the guest reboot). User-triggered ("Újraindítás most"); the controller batches all pending
// drives into one restart.
func (s *Server) handleGuestReboot(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.guestAttach == nil {
		writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
		return
	}
	if r.ContentLength != 0 {
		var req guestRebootRequest
		if !decodeBody(w, r, &req) {
			return
		}
		if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
			return
		}
	}
	base := s.baseCtx
	if base == nil {
		base = context.Background()
	}
	go func() {
		// Detached: pct reboot blocks ~30s until the guest is back; don't tie it to the request ctx.
		rebootCtx, cancel := context.WithTimeout(base, 5*time.Minute)
		defer cancel()
		if err := s.guestAttach.RebootGuest(rebootCtx, vmid); err != nil {
			s.logger.Error("local-api: guest-reboot failed", "vmid", vmid, "err", err)
		}
	}()
	s.logger.Warn("local-api: guest reboot requested (activating pending drive binds)", "vmid", vmid)
	writeStatus(w, http.StatusAccepted, true, map[string]any{"vmid": vmid, "rebooting": true}, "")
}

// validGuestMountPath accepts an absolute /mnt/<name> path with no traversal (the enroll convention
// root). Mirrors the controller's mount-name discipline so a hostile `where` can't escape /mnt.
func validGuestMountPath(p string) bool {
	if !strings.HasPrefix(p, "/mnt/") || strings.Contains(p, "..") {
		return false
	}
	rest := strings.TrimPrefix(p, "/mnt/")
	if rest == "" || strings.ContainsAny(rest, "/ \t") {
		return false // exactly one path component under /mnt
	}
	for _, c := range rest {
		if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
			continue
		}
		return false
	}
	return true
}

// freeMountSlot returns the lowest mpN (0..255) not present in the guest's current mountpoints. The
// bootstrap mount (mp9) and any existing data mounts are already in `mounts`, so they're skipped.
func freeMountSlot(mounts map[string]string) (string, bool) {
	for i := 0; i <= 255; i++ {
		key := "mp" + strconv.Itoa(i)
		if _, used := mounts[key]; !used {
			return key, true
		}
	}
	return "", false
}

type formatRequest struct {
	VMID   int    `json:"vmid"`
	Device string `json:"device"`
	FSType string `json:"fstype"`
	// Confirmed + DurableID authorize a USER-DATA data-bearing wipe by the customer's informed-
	// confirmation bound to the device's durable id. The agent RE-RESOLVES the device's durable id
	// and matches it against DurableID — a confirmation for one disk can't wipe another. Both are
	// INERT for system/backup devices (those stay operator-signature only — the role is the agent's,
	// never the caller's, so confirmed:true on a system device is refused).
	Confirmed bool   `json:"confirmed"`
	DurableID string `json:"durable_id"`
	// NOTE: any caller-supplied "blank"/"force" claim is still deliberately IGNORED — the agent
	// inspects the device itself (8C invariant).
}

// FormatResponse is POST /disks/format. On a data-bearing refusal (slice 10B) it SURFACES the
// bound op the operator must sign: the op class + the DURABLE device id (not the mutable path) +
// the fstype — so the operator can `felhom-opsign -op storage_wipe -durable-id <…>` offline, the
// hub queues it, and the agent's signed-jobs runner verifies + executes the wipe (re-resolving the
// durable id). This is the "records/reports the bound op intent so the operator sees what to sign".
type FormatResponse struct {
	VMID        int    `json:"vmid"`
	Device      string `json:"device"`
	Formatted   bool   `json:"formatted"`
	DataBearing bool   `json:"data_bearing"`
	Reason      string `json:"reason"`
	// Role is the agent's authoritative tier for the device (system | backup | user-data).
	Role string `json:"role,omitempty"`
	// NeedsConfirmation is set on a USER-DATA data-bearing refusal: the customer must re-submit with
	// confirmed:true + DurableID (below) after the controller's type-to-confirm UI. NOT an operator
	// signature — the customer authorizes the wipe of their own data drive.
	NeedsConfirmation bool   `json:"needs_confirmation,omitempty"`
	DurableID         string `json:"durable_id,omitempty"` // the durable id to confirm against (user-data)
	// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the exact op the operator must sign.
	PendingOp *PendingOp `json:"pending_op,omitempty"`
}

// PendingOp is the bound destructive intent the operator must sign offline (slice 10B). Params bind
// to the DURABLE device id so the signed authorization can't be retargeted to another disk.
type PendingOp struct {
	Op        string `json:"op"`         // e.g. "storage_wipe"
	HostScope string `json:"host_scope"` // the agent's host id (anti-retarget target)
	DurableID string `json:"durable_id"` // byid:…|byuuid:… — the device's stable identity
	FSType    string `json:"fstype"`     // the filesystem to mkfs after the wipe
}

// errFormatClientGone signals the request context was cancelled (client/controller deadline) while the
// detached mkfs keeps running — the handler returns without writing; the job record records the outcome.
var errFormatClientGone = fmt.Errorf("format client gone (mkfs continues detached)")

// awaitFormat waits for the detached mkfs result, OR returns errFormatClientGone if the request context
// is cancelled first. Crucially the mkfs itself runs off s.baseCtx, so a cancelled request never kills it
// (F20-BUG3) — abandoning the wait here only abandons the HTTP response, not the format.
func (s *Server) awaitFormat(reqCtx context.Context, done <-chan error, vmid int, device string) error {
	select {
	case err := <-done:
		return err
	case <-reqCtx.Done():
		s.logger.Warn("local-api: format client disconnected — mkfs continues detached (poll GET /disks/format/status)",
			"vmid", vmid, "device", device)
		return errFormatClientGone
	}
}

// handleDiskFormatStatus reports the most-recent/in-flight format job (F20-BUG3), so a controller whose
// request timed out (or that reconnects after an agent restart) can learn the real outcome instead of
// assuming failure. Self-scoped (benign read).
func (s *Server) handleDiskFormatStatus(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.formatJobs == nil {
		writeOK(w, map[string]any{"vmid": vmid, "phase": "idle"})
		return
	}
	job := s.formatJobs.get()
	if job == nil {
		writeOK(w, map[string]any{"vmid": vmid, "phase": "idle"})
		return
	}
	writeOK(w, map[string]any{
		"vmid": vmid, "phase": job.Phase, "device": job.Device, "fstype": job.FSType,
		"durable_id": job.DurableID, "error": job.Error, "started_at": job.StartedAt, "updated_at": job.UpdatedAt,
		"job_id": job.JobID,
	})
}

// handleDiskFormat is the security centerpiece. The agent INSPECTS the device; if it is
// data-bearing it is classified destructive and the gate refuses it `pending_signature` — the
// caller's claim is never trusted. Only a device the agent itself reads as blank is formatted.
func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil || s.diskGate == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	var req formatRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	if err := storage.ValidateBlockDevice(req.Device); err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}
	if err := storage.ValidateFSType(req.FSType); err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}

	// AGENT-INTERNAL device inspection — NEVER the caller's claim.
	probe, err := s.disks.InspectDevice(r.Context(), req.Device)
	if err != nil {
		s.logger.Error("local-api: format device inspect", "device", req.Device, "err", err)
		// inspect error → fail-safe data-bearing (probe.DataBearing() is true on !Probed)
	}
	if !probe.DataBearing() {
		// Blank device → benign → mkfs (role is irrelevant; there is nothing to destroy). F20-BUG3: run
		// it DETACHED off s.baseCtx so a request/client deadline can't SIGKILL mkfs mid-write; we still
		// wait here to return the synchronous result (backward-compatible with the controller's client).
		//
		// [audit D3, AGENT-001's benign-branch twin] anti-retarget: bind the format to the device's
		// durable id and re-resolve it to the CURRENT device (re-derive + exact match + re-inspect
		// STILL blank) — then format THAT device, never the mutable req.Device path. A /dev
		// re-enumeration in the window could otherwise mkfs a data-bearing disk that inherited the
		// node, with neither the DataBearing customer-confirm nor any durable-id binding. A device with
		// no durable id cannot be bound → refused (a path-only format is what the guard prevents).
		blankDurable, derr := s.deviceDurableID(req.Device)
		if derr != nil || blankDurable == "" {
			s.logger.Warn("local-api: blank format REFUSED — device has no durable id to bind (anti-retarget)",
				"vmid", vmid, "device", req.Device, "err", derr)
			writeStatus(w, http.StatusConflict, false,
				FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: false},
				"format refused: device has no durable id to bind the format to (path-only formats are not permitted)")
			return
		}
		device, rerr := s.reresolveBlank(r.Context(), blankDurable)
		if rerr != nil {
			s.logger.Warn("local-api: blank format REFUSED at anti-retarget re-resolve",
				"vmid", vmid, "req_device", req.Device, "durable_id", blankDurable, "err", rerr)
			writeStatus(w, http.StatusConflict, false,
				FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: false, DurableID: blankDurable},
				"format refused (device may have changed since inspection): "+rerr.Error())
			return
		}
		done := s.startFormatDetached(device, blankDurable, req.FSType, true)
		if err := s.awaitFormat(r.Context(), done, vmid, device); err != nil {
			if err == errFormatClientGone {
				return // client gone; mkfs continues detached + the job record records the outcome
			}
			s.logger.Error("local-api: format", "vmid", vmid, "device", device, "err", err)
			writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
			return
		}
		writeOK(w, FormatResponse{VMID: vmid, Device: device, Formatted: true, DataBearing: false, DurableID: blankDurable, Reason: "blank device formatted " + req.FSType})
		return
	}

	// Data-bearing → TIER by the agent's authoritative role classification (its own inspection,
	// never the caller's claim). The agent also re-resolves the device's durable id; the customer's
	// confirmation must bind to it.
	role := s.deviceRole(r.Context(), req.Device)
	deviceDurable, derr := s.deviceDurableID(req.Device)
	if derr != nil {
		deviceDurable = "" // refusal still stands; binding/pending-op just lack the id
	}
	dec := s.diskGate.AuthorizeWipe(WipeRequest{
		Role: string(role), DeviceDurableID: deviceDurable,
		Confirmed: req.Confirmed, ConfirmDurableID: req.DurableID,
	})

	if dec.Allowed {
		// USER-DATA, customer-confirmed (durable-id-bound). The gate already AUDITED it.
		// [AGENT-001] anti-retarget: re-resolve the confirmed durable id to the CURRENT
		// device, require the re-derived id to match, and confirm it is still
		// data-bearing — then format THAT device, never the mutable req.Device path.
		// This closes the classify→mkfs TOCTOU (a /dev reassignment in the window could
		// otherwise wipe a different physical disk). Mirrors signedjobs.WipeExecutor.
		device, rerr := s.reresolveWipe(r.Context(), deviceDurable)
		if rerr != nil {
			s.logger.Warn("local-api: customer-confirmed wipe REFUSED at anti-retarget re-resolve",
				"vmid", vmid, "req_device", req.Device, "durable_id", deviceDurable, "err", rerr)
			writeStatus(w, http.StatusConflict, false,
				FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true,
					Role: string(role), DurableID: deviceDurable, Reason: probe.Reason()},
				"wipe refused (device may have changed since confirmation): "+rerr.Error())
			return
		}
		// F20-BUG3: run the destructive mkfs DETACHED off s.baseCtx (bound durable id recorded for
		// restart-recovery), so a request/client deadline can never SIGKILL it mid-write and corrupt the
		// disk. We still wait to return the synchronous result (backward-compatible with the controller).
		done := s.startFormatDetached(device, deviceDurable, req.FSType, false)
		if err := s.awaitFormat(r.Context(), done, vmid, device); err != nil {
			if err == errFormatClientGone {
				return // client gone; the wipe continues detached + survives a restart via the job record
			}
			s.logger.Error("local-api: customer-confirmed format", "vmid", vmid, "device", device, "err", err)
			writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
			return
		}
		s.logger.Warn("local-api: USER-DATA data-bearing format — CUSTOMER CONFIRMED (no operator signature)",
			"vmid", vmid, "device", device, "durable_id", deviceDurable, "fstype", req.FSType, "why", probe.Reason())
		writeOK(w, FormatResponse{VMID: vmid, Device: device, Formatted: true, DataBearing: true,
			Role: string(role), DurableID: deviceDurable, Reason: "customer-confirmed wipe (" + probe.Reason() + ")"})
		return
	}

	if dec.Tier == "customer_confirmable" {
		// USER-DATA refusal: either awaiting the customer's confirmation, or the confirmation didn't
		// bind to THIS device. Surface the durable id to confirm against — NOT an operator signature.
		msg := "device is data-bearing — customer confirmation required"
		if !dec.NeedsConfirmation {
			msg = "confirmation does not match this device — refused (" + dec.Reason + ")"
		}
		s.logger.Warn("local-api: user-data data-bearing format refused",
			"vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "reason", dec.Reason, "why", probe.Reason())
		writeStatus(w, http.StatusForbidden, false,
			FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role),
				NeedsConfirmation: dec.NeedsConfirmation, DurableID: deviceDurable, Reason: probe.Reason()}, msg)
		return
	}

	// SYSTEM / BACKUP refusal: operator signature required. Surface the bound op to sign (the durable
	// id binds the signed wipe to THIS exact physical disk). confirmed:true was ignored — by role.
	var pending *PendingOp
	if deviceDurable != "" {
		pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: deviceDurable, FSType: req.FSType}
		s.logger.Warn("local-api: protected (system/backup) data-bearing format refused — PENDING OPERATOR SIGNATURE",
			"vmid", vmid, "device", req.Device, "role", role, "durable_id", deviceDurable, "fstype", req.FSType,
			"why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+deviceDurable)
	} else {
		s.logger.Warn("local-api: protected data-bearing format refused (no durable id)",
			"vmid", vmid, "device", req.Device, "role", role, "why", probe.Reason())
	}
	writeStatus(w, http.StatusForbidden, false,
		FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role), PendingOp: pending, Reason: probe.Reason()},
		"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
}

// boundUnderParent reports whether a drive's felhom-data is bound at its stable guest path AND visible
// inside the guest — a guest reboot leaves the host bind in place but invisible to the guest until
// re-propagated. Injectable via s.boundCheck for tests; defaults to the guest-namespace mount check.
//
// This is VISIBILITY, not liveness (R-117). It compares only the mount point, so it stays true over a
// bind whose device has gone; do not read it as "usable in the guest" — that is the whole three-term
// conjunction at the two /disks construction sites, whose third term is bindUsable.
func (s *Server) boundUnderParent(ctx context.Context, vmid int, stablePath string) bool {
	if s.boundCheck != nil {
		return s.boundCheck(stablePath)
	}
	if s.guestAttach == nil {
		return isHostMountpoint(stablePath)
	}
	return s.guestAttach.GuestSeesMount(ctx, vmid, stablePath)
}

// devicePresent reports whether the drive's BACKING DEVICE is still there, by asking whether its RAW
// host mount is still a mountpoint (R-113).
//
// WHY THE RAW MOUNT AND NOT THE BIND. The raw mount at /mnt/<name> is a systemd mount unit bound to
// its device: when the device goes, the unit stops and the mountpoint disappears. The agent's own bind
// of <raw>/felhom-data under the shared parent is an ordinary bind — nothing ties it to the device, so
// its mountinfo entry OUTLIVES the device as a stale shell. Measured live in E-2d with the device
// pulled: `/mnt/mentes2` NOT mounted while `/mnt/felhom-drives/mentes2` still read
// `/dev/sdb[/felhom-data]` (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.2). Keying presence on the
// survivor is exactly why the controller's drive-absent gate could never fire.
//
// An empty raw path means we have nothing to ask about — return TRUE (unknown), never false. Absent
// stops a customer's apps, so "cannot tell" must never be reported as "gone".
func (s *Server) devicePresent(rawMountPath string) bool {
	if rawMountPath == "" {
		return true // cannot tell → never claim absent
	}
	if s.deviceCheck != nil {
		return s.deviceCheck(rawMountPath)
	}
	return isHostMountpoint(rawMountPath)
}

// bindUsable is the THIRD term of the BoundUnderParent conjunction (R-117): the bind must not only exist
// and be guest-visible, it must actually WORK. The first two terms are path-presence tests and are both
// satisfied by a bind that names the drive that went away — measured live, with EIO on every read and
// write while the payload read healthy and the gate restarted the customer's apps onto it.
//
// UNKNOWN counts as usable, via BindLiveness.Usable — the same "cannot tell → never absent" rule
// devicePresent applies above, and for the same reason: a false absent stops a working customer's apps.
// Injectable via s.livenessCheck; the default reads /proc only and issues NO block I/O (CLAUDE.md).
func (s *Server) bindUsable(stable, rawMountPath string) bool {
	if s.livenessCheck != nil {
		return s.livenessCheck(stable, rawMountPath).Usable()
	}
	return bindLiveness(stable, rawMountPath).Usable()
}

// guestBoundPaths returns the set of guest mountpoint paths (the `mp=` of each entry in the guest's
// config) — i.e. the host drives actually BOUND into the guest. F9: this is the guest-attached signal
// (`GuestAttached`) that distinguishes a guest-usable drive from one merely present on the host. A bind
// created by AttachBind has `mp=<where>` where `where` is the drive's host mount path, so a storage
// target is guest-attached iff its MountPath is in this set. Best-effort: a config-read error yields an
// empty set (reported as not-attached — the safe direction).
func (s *Server) guestBoundPaths(ctx context.Context, vmid int) map[string]bool {
	out := map[string]bool{}
	if s.guests == nil {
		return out
	}
	cfg, err := s.guests.GuestConfig(ctx, vmid)
	if err != nil {
		s.logger.Warn("local-api: guest-attached check — could not read guest config", "vmid", vmid, "err", err)
		return out
	}
	for _, spec := range cfg.MountPoints() {
		if _, mp, _ := parseMount(spec); mp != "" {
			out[mp] = true
		}
	}
	return out
}

// guestSlotForPath returns the mountpoint slot (mpN) whose bind targets the guest path `where`, or ""
// if none. Used by decommission/eject to find the slot to `--delete` (the C1 fix). Best-effort: a
// config-read error yields "" (nothing to detach — the safe direction).
func (s *Server) guestSlotForPath(ctx context.Context, vmid int, where string) string {
	if s.guests == nil {
		return ""
	}
	cfg, err := s.guests.GuestConfig(ctx, vmid)
	if err != nil {
		s.logger.Warn("local-api: slot-for-path — could not read guest config", "vmid", vmid, "where", where, "err", err)
		return ""
	}
	for slot, spec := range cfg.MountPoints() {
		if _, mp, _ := parseMount(spec); mp == where {
			return slot
		}
	}
	return ""
}

// recordGuestBind persists that the drive at `where` (by its durable-id) is enrolled into `vmid`, so the
// startup re-assert (ReassertGuestBinds) can restore the bind after a re-provision (F9). Best-effort.
func (s *Server) recordGuestBind(ctx context.Context, vmid int, where string) {
	if s.guestBinds == nil {
		return
	}
	id := s.durableIDForMount(ctx, where)
	if id == "" {
		s.logger.Warn("local-api: guest-bind not recorded — durable-id unresolved", "vmid", vmid, "where", where)
		return
	}
	if err := s.guestBinds.Record(vmid, id); err != nil {
		s.logger.Warn("local-api: guest-bind record failed", "vmid", vmid, "where", where, "durable_id", id, "err", err)
		return
	}
	s.logger.Info("local-api: guest-bind recorded for startup re-assert", "vmid", vmid, "where", where, "durable_id", id)
}

// ReassertGuestBinds is the intermediary-model startup reconcile: for every ENROLLED + PRESENT enrolled
// drive, it ensures the drive's felhom-data is bound under the shared parent (host-side, via AttachDrive)
// so it's live in the guest. This is PURE host-side mount work — NO pct, NO guest-config read, NO reboot
// — and it fixes F9 (a re-provision drops nothing host-side) AND drive-returned reconnection for free.
// It first ensures the host shared parent exists (so a fresh boot has it before any bind). "On durable-id
// match" + intent-gated: a swapped, ejected, or decommissioned drive is never auto-bound. AttachDrive is
// idempotent (a no-op when already bound), so this is safe to call repeatedly. Runs at agent startup and
// on a drive-returned event.
func (s *Server) ReassertGuestBinds(ctx context.Context) {
	if s.guestBinds == nil || s.guestAttach == nil {
		return
	}
	// Make sure the host stable parent is shared + boot-persistent before we bind anything under it.
	if err := s.guestAttach.EnsureSharedParent(ctx); err != nil {
		s.logger.Warn("reconcile: shared parent ensure failed — binds may not propagate live", "err", err)
	}
	// durable-id -> current host mount path (present drives only), from the agent's own storage view.
	mountByDurable := map[string]string{}
	if targets, err := s.storage.Observe(ctx); err == nil {
		for _, t := range targets {
			if t.DurableID != "" && t.MountPath != "" {
				mountByDurable[t.DurableID] = t.MountPath
			}
		}
	} else {
		// Observe failing is not fatal — a RAW enrolled drive wouldn't be in it anyway; fall through to
		// the mount-table scan below so raw drives still get re-asserted.
		s.logger.Warn("reconcile: storage view unavailable — using mount-table only", "err", err)
	}
	// Impl-2b: a RAW enrolled drive is NOT a PVE storage, so Observe misses it and its guest-bind would
	// never be re-asserted (post-reboot/reconnect). Augment the map from the mount table: each raw
	// /mnt/<name> mount → its device fs-UUID (uuid:<…>). Skip the /mnt/felhom-drives bind (AttachDrive
	// wants the RAW mount path). Observe entries win (already set).
	if s.host != nil {
		if mounts, err := s.host.Mounts(); err == nil {
			for _, m := range mounts {
				if strings.HasPrefix(m.MountPoint, "/mnt/felhom-drives") {
					continue // the bind, not the raw mount
				}
				if uuid, ok := s.host.ResolveUUID(m.Device); ok && uuid != "" {
					if _, exists := mountByDurable["uuid:"+uuid]; !exists {
						mountByDurable["uuid:"+uuid] = m.MountPoint
					}
				}
			}
		}
	}
	guests := s.guestBinds.Guests()
	s.logger.Debug("reconcile: guest-bind re-assert pass",
		"guests", len(guests), "resolved_mounts", len(mountByDurable))
	for vmid, ids := range guests {
		for _, id := range ids {
			// Intent-aware (B2, load-bearing): NEVER bind a drive that is not currently `enrolled` — an
			// ejected or decommissioned drive must not auto-rebind, even if still host-mounted. A nil
			// intent store falls back to bind-all (ungated), matching the watchdog's nil-intent rule.
			if s.intent != nil && s.intent.Get(id) != storage.IntentEnrolled {
				s.logger.Warn("reconcile: skipping non-enrolled drive (intent-gated)", "vmid", vmid, "durable_id", id, "intent", string(s.intent.Get(id)))
				continue
			}
			where, present := mountByDurable[id]
			if !present {
				s.logger.Warn("reconcile: enrolled drive not present (durable-id absent) — skipping", "vmid", vmid, "durable_id", id)
				continue
			}
			stable, err := s.guestAttach.AttachDrive(ctx, vmid, where)
			if err != nil {
				s.logger.Error("reconcile: AttachDrive failed", "vmid", vmid, "where", where, "err", err)
				continue
			}
			s.logger.Info("reconcile: enrolled drive bound under shared parent (live, no reboot)",
				"vmid", vmid, "where", where, "guest_path", stable, "durable_id", id)
		}
	}
}

// durableIDForMount resolves the durable-id of the storage mounted at `where` (from the agent's own
// storage view) — the key the intent store records enroll/eject against. "" if not resolvable.
func (s *Server) durableIDForMount(ctx context.Context, where string) string {
	// Preferred: the storage view (PVE storages) already carries the derived durable-id.
	if targets, err := s.storage.Observe(ctx); err == nil {
		for _, t := range targets {
			if t.MountPath == where {
				return t.DurableID
			}
		}
	}
	// Fallback (Impl-2b): a RAW enrolled drive is NOT a PVE storage, so it never appears in Observe —
	// which left its enroll/eject intent + guest-bind unrecorded ("durable-id unresolved"). Resolve the
	// fs-UUID directly from the mount table: the device mounted at `where` → its by-uuid identity. This
	// is the SAME scheme Observe derives ("uuid:<fs-uuid>"), so intent keys stay consistent.
	if s.host != nil {
		if mounts, err := s.host.Mounts(); err == nil {
			for _, m := range mounts {
				if m.MountPoint == where {
					if uuid, ok := s.host.ResolveUUID(m.Device); ok && uuid != "" {
						return "uuid:" + uuid
					}
					break
				}
			}
		}
	}
	return ""
}

// recordIntent records enroll/eject intent for the drive at `where`, best-effort (a nil store, an
// unresolved durable-id, or a write error is logged, never fatal — intent is a self-heal aid, not a
// gate on the user's action). `action` is "enrolled" or "ejected".
func (s *Server) recordIntent(ctx context.Context, where, action string) {
	if s.intent == nil {
		return
	}
	id := s.durableIDForMount(ctx, where)
	if id == "" {
		s.logger.Warn("local-api: intent not recorded — durable-id unresolved", "where", where, "action", action)
		return
	}
	var err error
	switch action {
	case "enrolled":
		err = s.intent.SetEnrolled(id)
	case "ejected":
		err = s.intent.SetEjected(id)
	case "decommissioned":
		err = s.intent.SetDecommissioned(id)
	}
	if err != nil {
		s.logger.Warn("local-api: intent record failed", "where", where, "action", action, "durable_id", id, "err", err)
		return
	}
	s.logger.Info("local-api: drive intent recorded", "where", where, "action", action, "durable_id", id)
}

// hostReader returns the injected root-free host topology reader, or the production default. The seam
// keeps the role classification (SystemDisks) testable without touching the real /proc /dev /sys.
func (s *Server) hostReader() storage.HostReader {
	if s.host != nil {
		return s.host
	}
	return storage.NewProcHostReader()
}

// backupTargetAt reports the configured backup TIER whose storage is mounted at `where`, or "" when
// none is. E-2c.
//
// WHY THIS IS NOT A ROLE RECLASSIFICATION. The obvious fix is to make RoleForStorage return
// RoleBackup for the target's storage, and it is wrong here: on both demo boxes the drive that now
// holds the whole-guest archives is ALSO the enrolled user-data drive (E-1 put the vzdump target on
// the drive's own mountpoint, beside felhom-data). Reclassifying it would refuse every legitimate
// eject/decommission of the customer's own data drive — an over-correction that trades one silent
// failure for a permanent obstruction. So this is a SEPARATE, narrower gate that names exactly what
// it protects and leaves the role vocabulary alone.
//
// It resolves through the agent's OWN storage view (never the caller's claim) and fails OPEN — an
// unreadable view returns "" so this gate cannot block on a transient error. That is safe because it
// sits BEHIND the role gate, which already fails SAFE on the same error: an unresolvable mount is
// refused there before it ever reaches this check.
func (s *Server) backupTargetAt(ctx context.Context, where string) string {
	if where == "" || s.storage == nil {
		return ""
	}
	targets, err := s.storage.Observe(ctx)
	if err != nil {
		return "" // fail OPEN — the role gate already fails SAFE on this same error
	}
	for _, t := range s.tiers {
		if t.TargetID == "" {
			continue
		}
		for _, tgt := range targets {
			if tgt.Name == t.TargetID && tgt.MountPath == where {
				return t.TargetID
			}
		}
	}
	return ""
}

// refuseIfBackupTarget refuses a destructive drive op when `where` backs a configured backup tier,
// and reports whether it did. The message names the storage AND the remedy: the operation is not
// forbidden forever, it is ordered — reassign the backup target first, then the drive is free.
//
// Ejecting the drive that holds the only local whole-guest backup is exactly the silent-degradation
// class this arc has been closing: it succeeds, nothing alarms, and the box quietly loses its
// drive-loss protection while still reporting a configured tier.
func (s *Server) refuseIfBackupTarget(ctx context.Context, w http.ResponseWriter, op string, vmid int, where string) bool {
	target := s.backupTargetAt(ctx, where)
	if target == "" {
		return false
	}
	s.logger.Warn("local-api: protected — "+op+" refused: the mount backs a configured backup tier",
		"vmid", vmid, "where", where, "target", target)
	writeErr(w, http.StatusConflict,
		"this drive is the whole-guest backup target ("+target+") — "+op+" refused. "+
			"Reassign the backup target to another drive first, or the box loses its local drive-loss protection.")
	return true
}

// roleForMountPath resolves the AUTHORITATIVE protection role of the storage mounted at `where`, from
// the agent's OWN storage view + host topology (never the caller's claim). It mirrors deviceRole but
// keys on the mount path (the eject input). It FAILS SAFE to system (most-protected) on any
// ambiguity — a view error, an unrecognizable mount source, or `where` absent from both the storage
// view and the mount table — so an unresolvable eject is refused rather than silently unmounted.
func (s *Server) roleForMountPath(ctx context.Context, where string) storage.DeviceRole {
	sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
	targets, err := s.storage.Observe(ctx)
	if err != nil {
		// Can't read the view → protected. The mount-table fallback below must NOT run here: with
		// the PVE view down its containment pass is blind, and falling through to raw classification
		// could label a backup-backing device user-data — a PERMISSIVE regression.
		return storage.RoleSystem
	}
	for _, t := range targets {
		if t.MountPath == where {
			return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
		}
	}
	// Fallback (campaign F2, 2026-07-06 — mirrors durableIDForMount's Impl-2b): a RAW enrolled
	// user-data drive is NOT a PVE storage, so it never appears in Observe — which made this
	// function fail-safe every such mount to `system` and the eject/decommission gates 403 EVERY
	// user-data drive in the standard topology (journal: where=/mnt/teszt_enroll role=system).
	// Resolve the mount's backing device from the host mount table, then classify device-keyed:
	//   - non-/dev source (NAS "server:/export", tmpfs, …) → system (never drive-ejectable);
	//   - device on the same whole disk as a KNOWN storage target → THAT target's role (containment:
	//     felhom-usb/felhom-flash are dir storages with backup content — their disk must never
	//     become ejectable through this path);
	//   - otherwise RoleForRawDevice (system-disk membership; fail-safe to system).
	mounts, merr := s.hostReader().Mounts()
	if merr != nil {
		return storage.RoleSystem // can't read the mount table → protected
	}
	for _, m := range mounts {
		if m.MountPoint != where {
			continue
		}
		if !strings.HasPrefix(m.Device, "/dev/") {
			return storage.RoleSystem // network/virtual source — has its own lifecycle, protected here
		}
		// Containment pass over the ALREADY-FETCHED targets (never re-Observe: a racing error there
		// would degrade to the permissive raw path). Compare at whole-disk granularity: targets carry
		// the PARTITION as BackingDevice (a dir storage on /dev/sdb1) while a raw enrolled drive mounts
		// the WHOLE disk (/dev/sdb) — SameWholeDisk normalizes both so a backup disk stays protected.
		for _, t := range targets {
			if t.BackingDevice != "" && storage.SameWholeDisk(t.BackingDevice, m.Device) {
				return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
			}
		}
		// No known target claims this device → classify the raw device by system-disk membership.
		return storage.RoleForRawDevice(m.Device, sysDisks, sysKnown)
	}
	return storage.RoleSystem // no storage target and no mount-table entry → fail safe to protected
}

// deviceRole resolves a device's AUTHORITATIVE protection tier. It prefers a known storage target's
// role (so a PBS-backed device is recognized as backup), falling back to a raw-device classification
// (for a fresh disk not yet a PVE storage — the init flow). Defaults to system on ambiguity.
func (s *Server) deviceRole(ctx context.Context, device string) storage.DeviceRole {
	sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
	if targets, err := s.storage.Observe(ctx); err == nil {
		for _, t := range targets {
			if t.BackingDevice != "" && t.BackingDevice == device {
				return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
			}
		}
	}
	return storage.RoleForRawDevice(device, sysDisks, sysKnown)
}

// dependentGuests returns the VMIDs whose config has a mount whose storage backs the ejected
// mount path — best-effort (a scan failure yields an empty list; the eject still proceeds).
func (s *Server) dependentGuests(ctx context.Context, where string) []int {
	if s.guestList == nil {
		return nil
	}
	guests, err := s.guestList.ListLXC(ctx)
	if err != nil {
		s.logger.Warn("local-api: eject dependent-scan: list guests", "err", err)
		return nil
	}
	// Map each storage id whose mount path == `where` (from the storage view) → dependents.
	storeForPath := map[string]bool{}
	if targets, err := s.storage.Observe(ctx); err == nil {
		for _, t := range targets {
			if t.MountPath == where {
				storeForPath[t.Name] = true
			}
		}
	}
	var out []int
	for _, g := range guests {
		cfg, err := s.guests.GuestConfig(ctx, g.VMID)
		if err != nil {
			continue
		}
		for _, mp := range cfg.MountPoints() {
			store, mpPath, _ := parseMount(mp)
			if storeForPath[store] || mpPath == where {
				out = append(out, g.VMID)
				break
			}
		}
	}
	return out
}
