package storage

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"net"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// Network storage (NAS) — the bulk-media class (Part A1). A NAS share is mounted HOST-SIDE under
// /mnt/felhom-drives/<name> via a systemd .automount (on-demand + idle-unmount) + .mount pair; it
// propagates into the guest for free through the existing shared mp8 bind. This is a DISTINCT class
// from a physical drive: it carries NO durable-id, never enters the IntentStore, is never SMART-probed,
// and bypasses the enroll/eject/decommission/wipe/migrate machinery entirely (SPIKE-nas-storage Q7).
//
// The whole file is the validated, locked recipe from SPIKE-nas-storage-2026-06-29.md — match it, do
// not re-derive: NFS preferred (vers=4.1,soft,timeo=50,retrans=2,noatime), SMB fallback, the +100000
// uid rule (container uid/gid N = host N+100000), automount idle-unmount for failure isolation.

const (
	// NetworkMountRoot is the ONLY place a network mount may live — under the existing shared mp8
	// bind parent, so it propagates into the guest with no new mountpoint and no restart. The role
	// gate (NetworkMountRole) confines every network mount to this namespace.
	NetworkMountRoot = "/mnt/felhom-drives"

	// lxcUIDOffset is the unprivileged-LXC id map base: a container uid/gid N appears on the host as
	// N+100000 (verified against the live felhom-usb userdata). The NAS must present/own/squash files
	// as container_id+100000 so the guest sees its native id and reads+writes (SPIKE Q5). This is the
	// whole +100000 recipe; a naive +0 (anonuid=1000 / uid=1000) lands as nobody:nogroup → not writable.
	lxcUIDOffset = 100000

	// netUnitMarker headers every network-storage unit. It is the explicit guard that keeps a network
	// mount OUT of the drive machinery: parseFelhomMountUnit refuses any unit carrying it, so the
	// host-reboot drive re-assert (ReassertEnrolledMounts) never touches a NAS mount (Scenario D).
	netUnitMarker = "Managed by felhom-agent (network storage)"

	// defaultIdleTimeoutSec is the automount TimeoutIdleSec: with no app reading, the share auto-unmounts,
	// so an idle NAS reboot is a non-event and the stale-mount window is minimised (SPIKE recommendation).
	defaultIdleTimeoutSec = 60

	// netReachTimeout bounds the per-share liveness endpoint dial so a black-holed NAS cannot wedge a
	// list call (the mount itself may be EIO/D-state; we probe the endpoint, never stat the mount).
	netReachTimeout = 2 * time.Second
)

// NetworkProtocol is the wire protocol of a network mount.
type NetworkProtocol string

const (
	ProtocolNFS NetworkProtocol = "nfs"
	ProtocolSMB NetworkProtocol = "smb"
)

// NetworkMountSpec describes one NAS share to mount host-side. It is NOT a drive: no durable-id, no
// device, no role lifecycle. MappedUID/MappedGID are the CONTAINER ids (e.g. 1000); the agent applies
// the +100000 offset where it matters (the SMB client mount). CredsRef points at the 0600 out-of-band
// SMB credentials file (empty for NFS) — never the credentials themselves.
type NetworkMountSpec struct {
	Name           string          // share name → mountpoint /mnt/felhom-drives/<Name>; a single safe path segment
	Protocol       NetworkProtocol // nfs | smb
	Server         string          // NAS host or IP
	Export         string          // NFS export path (/srv/media) or SMB share name (media)
	MappedUID      int             // CONTAINER uid the media app runs as (host = +100000)
	MappedGID      int             // CONTAINER gid
	CredsRef       string          // SMB only: absolute path to the 0600 credentials file (out-of-band)
	IdleTimeoutSec int             // automount idle-unmount window; 0 → defaultIdleTimeoutSec
}

// NetworkMountStatus is the per-share liveness surface (SPIKE Q7 health model): is it configured,
// currently mounted, and is the NAS endpoint reachable. Health degrades to "unreachable" for the
// affected share ONLY — it never folds into the box's overall health.
type NetworkMountStatus struct {
	Name       string `json:"name"`
	Protocol   string `json:"protocol"`
	Server     string `json:"server"`
	Export     string `json:"export"`
	Where      string `json:"where"`
	Configured bool   `json:"configured"` // the automount unit is installed
	Mounted    bool   `json:"mounted"`    // currently mounted (false when idle-unmounted — normal, not a fault)
	Reachable  bool   `json:"reachable"`  // the NAS endpoint is TCP-reachable
	Health     string `json:"health"`     // ok | idle | unreachable
}

// Network mount health vocabulary.
const (
	NetHealthOK          = "ok"          // reachable + mounted: serving
	NetHealthIdle        = "idle"        // reachable + not mounted: automount idle-unmounted (benign)
	NetHealthUnreachable = "unreachable" // endpoint not reachable: the affected share is degraded
)

var (
	// reShareName: a single safe path segment for the share name (becomes the mountpoint dir + unit name).
	reShareName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
	// reNetServer: a hostname or IPv4/IPv6 literal — no metacharacters, no '/', no ':' path tricks.
	reNetServer = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`)
)

const maxShareNameLen = 64

// Where returns the mountpoint for this spec under the network-mount root.
func (s NetworkMountSpec) Where() string {
	return NetworkMountRoot + "/" + s.Name
}

// HostUID/HostGID apply the +100000 LXC offset — the host id the share must own/squash files as.
func (s NetworkMountSpec) HostUID() int { return s.MappedUID + lxcUIDOffset }
func (s NetworkMountSpec) HostGID() int { return s.MappedGID + lxcUIDOffset }

// idleTimeout returns the configured idle-unmount window or the default.
func (s NetworkMountSpec) idleTimeout() int {
	if s.IdleTimeoutSec > 0 {
		return s.IdleTimeoutSec
	}
	return defaultIdleTimeoutSec
}

// ValidateNetworkMountSpec is the security boundary for the network-mount surface — every value that
// reaches a systemd unit (and thus a root-triggered mount) is checked here before any unit is rendered.
// Mirrors validate.go's discipline: strict charset/length, no metacharacters, no traversal.
func ValidateNetworkMountSpec(s NetworkMountSpec) error {
	if s.Name == "" || len(s.Name) > maxShareNameLen || !reShareName.MatchString(s.Name) || s.Name == "." || s.Name == ".." {
		return fmt.Errorf("netmount: invalid share name %q (want a single safe segment)", s.Name)
	}
	switch s.Protocol {
	case ProtocolNFS, ProtocolSMB:
	default:
		return fmt.Errorf("netmount: unsupported protocol %q (want nfs|smb)", s.Protocol)
	}
	if s.Server == "" || len(s.Server) > 255 || !reNetServer.MatchString(s.Server) {
		return fmt.Errorf("netmount: invalid server %q", s.Server)
	}
	if err := validateExport(s.Protocol, s.Export); err != nil {
		return err
	}
	if s.MappedUID < 0 || s.MappedUID > 60000 || s.MappedGID < 0 || s.MappedGID > 60000 {
		return fmt.Errorf("netmount: mapped uid/gid out of range (uid=%d gid=%d; want 0..60000)", s.MappedUID, s.MappedGID)
	}
	if s.Protocol == ProtocolSMB {
		if s.CredsRef == "" {
			return fmt.Errorf("netmount: smb requires a credentials file reference")
		}
		if err := ValidateMountPath(s.CredsRef); err != nil {
			return fmt.Errorf("netmount: invalid credentials path: %w", err)
		}
	}
	// The mountpoint must validate AND must land under the network-mount root (the role gate's namespace).
	if err := ValidateMountPath(s.Where()); err != nil {
		return err
	}
	return nil
}

// validateExport checks the NFS export path / SMB share name. NFS export must be an absolute, safe path;
// the SMB share name a single safe segment.
func validateExport(proto NetworkProtocol, export string) error {
	if export == "" {
		return fmt.Errorf("netmount: empty export/share")
	}
	switch proto {
	case ProtocolNFS:
		if export[0] != '/' {
			return fmt.Errorf("netmount: nfs export must be an absolute path, got %q", export)
		}
		if err := ValidateMountPath(export); err != nil {
			return fmt.Errorf("netmount: invalid nfs export: %w", err)
		}
	case ProtocolSMB:
		if len(export) > maxShareNameLen || !reShareName.MatchString(export) {
			return fmt.Errorf("netmount: invalid smb share name %q", export)
		}
	}
	return nil
}

// NetworkMountRole gates network storage to the bulk-userdata namespace (SPIKE Q7: a NAS is selectable
// as a media app's data path but DISQUALIFIED as a system/backup/DB target). A network mount is
// user-data ONLY when it lands under NetworkMountRoot (the controller's bind blast radius); any other
// target is the most-protected role (system) → the caller refuses it. Pure → unit-tested.
func NetworkMountRole(where string) DeviceRole {
	clean := cleanMountPath(where)
	if clean == NetworkMountRoot || strings.HasPrefix(clean, NetworkMountRoot+"/") {
		return RoleUserData
	}
	return RoleSystem
}

// mountSource builds the systemd What= for the spec: server:/export (NFS) or //server/share (SMB).
func (s NetworkMountSpec) mountSource() string {
	if s.Protocol == ProtocolSMB {
		return "//" + s.Server + "/" + s.Export
	}
	return s.Server + ":" + s.Export
}

// fsType maps the protocol to the kernel filesystem type for the .mount unit.
func (s NetworkMountSpec) fsType() string {
	if s.Protocol == ProtocolSMB {
		return "cifs"
	}
	return "nfs4" // vers=4.1 → nfs4 (avoids the rpcbind/lock-manager surface of v3)
}

// mountOptions returns the exact, validated option set for the protocol (SPIKE Q2/Q5 +
// SPIKE-nas-verify Q4-vi):
//   - NFS: vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0 — soft is the failure-isolation
//     knob (clean EIO, never a wedge); the +100000 squash is the EXPORT's job (anonuid=101000), not
//     the client mount, so no uid appears here. retry=0 (SPIKE-nas-verify Q4-vi): without it a
//     dead-NAS on-demand access wedges the app until systemd's 90 s start cap (measured 91 s); with
//     it the access fails clean in ~3.8 s (ENODEV) AND the verify sees a classifiable
//     "No route to host" instead of a diagnostic-free systemd timeout. retry only governs retrying
//     a FAILED first attempt — the happy path is untouched, and each autofs re-access is a fresh
//     attempt anyway.
//   - SMB: vers=3.0,credentials=<file>,uid=<+100000>,gid=<+100000>,forceuid,forcegid,file_mode=0664,
//     dir_mode=0775,_netdev — modes are PLAIN octal (not setgid 2775); the client forces the
//     guest-visible owner to the mapped id so the container reads+writes. NO retry= here — retry is
//     a mount.nfs option; mount.cifs would reject it.
//
// Every interpolated value is pre-validated by ValidateNetworkMountSpec, so the string carries no
// newline / no extra directive. NEVER a default `hard` NFS mount (it wedges) — soft is mandatory.
func (s NetworkMountSpec) mountOptions() string {
	if s.Protocol == ProtocolSMB {
		return strings.Join([]string{
			"vers=3.0",
			"credentials=" + s.CredsRef,
			"uid=" + strconv.Itoa(s.HostUID()),
			"gid=" + strconv.Itoa(s.HostGID()),
			"forceuid",
			"forcegid",
			"file_mode=0664",
			"dir_mode=0775",
			"_netdev",
		}, ",")
	}
	return "vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0"
}

// renderNetworkMountUnit builds the .mount unit (triggered by the .automount; deliberately NO [Install]
// — we enable the .automount, not this). Marked with netUnitMarker so the drive machinery skips it.
func renderNetworkMountUnit(s NetworkMountSpec) string {
	var b strings.Builder
	b.WriteString("# " + netUnitMarker + " — do not edit by hand.\n")
	b.WriteString("[Unit]\n")
	fmt.Fprintf(&b, "Description=Felhom network storage %s (%s)\n", sanitizeDesc(s.Name), s.Protocol)
	// F12 (CAMPAIGN-3, CRITICAL): NO network-online.target ordering here. `_netdev` in Options is the
	// correct + sufficient network ordering for the REAL mount — systemd classes a _netdev mount under
	// remote-fs.target and orders it after the network without a hand-written After/Wants. A literal
	// `After=network-online.target` on this unit (which the .automount pulls in via local-fs) closed the
	// boot ordering cycle networking→local-fs→automount→network-online→networking; systemd broke it by
	// DELETING an arbitrary job (one boot lost networking entirely, the next lost the automount).
	b.WriteString("\n[Mount]\n")
	fmt.Fprintf(&b, "What=%s\n", s.mountSource())
	fmt.Fprintf(&b, "Where=%s\n", s.Where())
	fmt.Fprintf(&b, "Type=%s\n", s.fsType())
	fmt.Fprintf(&b, "Options=%s\n", s.mountOptions())
	return b.String()
}

// renderNetworkAutomountUnit builds the .automount unit (on-demand + idle-unmount). Enabling THIS is
// what realises the on-demand mount; the idle timeout makes an idle NAS reboot a non-event.
func renderNetworkAutomountUnit(s NetworkMountSpec) string {
	var b strings.Builder
	b.WriteString("# " + netUnitMarker + " — do not edit by hand.\n")
	b.WriteString("[Unit]\n")
	fmt.Fprintf(&b, "Description=Felhom network storage automount %s (%s)\n", sanitizeDesc(s.Name), s.Protocol)
	// F12 (CAMPAIGN-3, CRITICAL): the automount unit gets NO network relation of ANY kind. A trigger
	// needs no network — it just watches the mountpoint and fires the .mount on first access (the
	// .mount's `_netdev` then orders the real mount after the network). An automount is implicitly
	// ordered Before=local-fs.target; adding After/Wants=network-online.target here created the boot
	// ordering cycle that cost the host its network on one boot and its NAS on the next. Keep this unit
	// orderable before local-fs WITHOUT dragging the network into that transaction.
	b.WriteString("\n[Automount]\n")
	fmt.Fprintf(&b, "Where=%s\n", s.Where())
	fmt.Fprintf(&b, "TimeoutIdleSec=%d\n", s.idleTimeout())
	b.WriteString("\n[Install]\n")
	b.WriteString("WantedBy=multi-user.target\n")
	return b.String()
}

// parseNetworkMountUnit is the inverse of renderNetworkMountUnit for the fields the liveness list needs.
// ok=false unless the content is a felhom network-storage .mount unit (the netUnitMarker + a parseable
// network What=). Pure → unit-tested.
func parseNetworkMountUnit(content string) (proto, server, export, where string, ok bool) {
	if !strings.Contains(content, netUnitMarker) {
		return "", "", "", "", false
	}
	var what, fstype string
	for _, line := range strings.Split(content, "\n") {
		line = strings.TrimSpace(line)
		switch {
		case strings.HasPrefix(line, "What="):
			what = strings.TrimPrefix(line, "What=")
		case strings.HasPrefix(line, "Where="):
			where = strings.TrimPrefix(line, "Where=")
		case strings.HasPrefix(line, "Type="):
			fstype = strings.TrimPrefix(line, "Type=")
		}
	}
	if where == "" || what == "" {
		return "", "", "", "", false
	}
	switch fstype {
	case "cifs":
		proto = string(ProtocolSMB)
		// //server/share
		rest := strings.TrimPrefix(what, "//")
		if i := strings.IndexByte(rest, '/'); i > 0 {
			server, export = rest[:i], rest[i+1:]
		}
	case "nfs4", "nfs":
		proto = string(ProtocolNFS)
		if i := strings.IndexByte(what, ':'); i > 0 {
			server, export = what[:i], what[i+1:]
		}
	default:
		return "", "", "", "", false
	}
	if server == "" {
		return "", "", "", "", false
	}
	return proto, server, export, where, true
}

// networkHealth derives the per-share health string from reachability + mount state (SPIKE Q7).
func networkHealth(reachable, mounted bool) string {
	switch {
	case !reachable:
		return NetHealthUnreachable
	case mounted:
		return NetHealthOK
	default:
		return NetHealthIdle // reachable but idle-unmounted — normal automount steady state
	}
}

// netEndpoint returns the host:port the liveness probe dials for a protocol (NFS 2049, SMB 445).
func netEndpoint(proto, server string) string {
	port := "2049"
	if proto == string(ProtocolSMB) {
		port = "445"
	}
	return netJoin(server, port)
}

// ---- SudoHostOps: the privileged network-mount surface --------------------------------------------

// EnsureNetworkMount stages + installs the .mount and .automount units and enables the AUTOMOUNT (not
// the mount): the share then mounts on first access and idle-unmounts when quiet. Idempotent. It does
// NOT register a durable-id, NOT enter the IntentStore, NOT SMART-probe — a NAS is not a drive.
func (h *SudoHostOps) EnsureNetworkMount(ctx context.Context, spec NetworkMountSpec) error {
	if err := ValidateNetworkMountSpec(spec); err != nil {
		return err
	}
	// Template-drift reconcile: bring any already-installed units up to the current template before we
	// touch the unit dir (F12 — a pre-0.85 unit still carrying the network-online ordering gets rewritten
	// here even if the daemon-startup migration hasn't run in this process). Best-effort; never blocks add.
	h.MigrateNetworkUnits(ctx)
	// Defense in depth: never realise a network mount outside the user-data namespace.
	if NetworkMountRole(spec.Where()) != RoleUserData {
		return fmt.Errorf("netmount: refusing to mount outside the user-data namespace: %s", spec.Where())
	}
	mountUnit, err := UnitNameForMount(spec.Where())
	if err != nil {
		return err
	}
	automountUnit := strings.TrimSuffix(mountUnit, ".mount") + ".automount"

	// Ensure the mountpoint exists (systemd automount also creates it, but be explicit — the parent is
	// the shared bind root). Reuse the intermediary mkdir grant.
	if err := h.run(ctx, "/usr/bin/mkdir", "-p", spec.Where()); err != nil {
		return fmt.Errorf("netmount: mkdir %s: %w", spec.Where(), err)
	}

	if err := h.installUnit(ctx, mountUnit, renderNetworkMountUnit(spec)); err != nil {
		return err
	}
	h.logger.Debug("netmount: mount unit installed", "unit", mountUnit)
	if err := h.installUnit(ctx, automountUnit, renderNetworkAutomountUnit(spec)); err != nil {
		return err
	}
	h.logger.Debug("netmount: automount unit installed", "unit", automountUnit,
		"idle_timeout_s", spec.idleTimeout())
	if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
		return fmt.Errorf("netmount: daemon-reload: %w", err)
	}
	// Enable + start the AUTOMOUNT (creates the autofs trigger; idempotent).
	if err := h.run(ctx, h.bins.Systemctl, "enable", "--now", "--", automountUnit); err != nil {
		return fmt.Errorf("netmount: enabling automount %s: %w", automountUnit, err)
	}
	h.logger.Debug("netmount: automount enabled + started", "unit", automountUnit)
	h.logger.Info("netmount: ensured network mount", "name", spec.Name, "proto", spec.Protocol,
		"server", spec.Server, "export", spec.Export, "where", spec.Where(), "host_uid", spec.HostUID())
	return nil
}

// installUnit stages an agent-owned unit file then root-installs it into the unit dir (atomic, fixed
// mode/owner) — the same staging pattern as EnsureMount.
func (h *SudoHostOps) installUnit(ctx context.Context, unitName, content string) error {
	if err := os.MkdirAll(h.stageDir, 0o700); err != nil {
		return fmt.Errorf("netmount: staging dir: %w", err)
	}
	stagePath := filepath.Join(h.stageDir, unitName)
	if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
		return fmt.Errorf("netmount: staging unit %s: %w", unitName, err)
	}
	dest := filepath.Join(h.unitDir, unitName)
	if err := h.run(ctx, h.bins.Install, "-o", "root", "-g", "root", "-m", "0644", "--", stagePath, dest); err != nil {
		return fmt.Errorf("netmount: installing unit %s: %w", unitName, err)
	}
	return nil
}

// RemoveNetworkMount stops + disables the automount, stops the mount, and removes both unit files (and
// the staged copies). It NEVER routes through the drive eject/decommission path — a NAS has no device
// lifecycle. Best-effort on the per-step stop/disable (a not-loaded unit is fine); the file removal is
// the authoritative "gone" signal.
func (h *SudoHostOps) RemoveNetworkMount(ctx context.Context, name string) error {
	if name == "" || len(name) > maxShareNameLen || !reShareName.MatchString(name) || name == "." || name == ".." {
		return fmt.Errorf("netmount: invalid share name %q", name)
	}
	where := NetworkMountRoot + "/" + name
	mountUnit, err := UnitNameForMount(where)
	if err != nil {
		return err
	}
	automountUnit := strings.TrimSuffix(mountUnit, ".mount") + ".automount"

	// Stop the automount first (so it can't re-trigger the mount), then the mount. Tolerate "not loaded".
	for _, step := range [][]string{
		{"stop", "--", automountUnit},
		{"disable", "--", automountUnit},
		{"stop", "--", mountUnit},
	} {
		err := h.run(ctx, h.bins.Systemctl, step...)
		h.logger.Debug("netmount: remove step", "verb", step[0], "unit", step[len(step)-1],
			"ok", err == nil) // a "not loaded" failure here is expected + tolerated
	}
	// F2 (CAMPAIGN-3): clear any failed/start-limit runtime state on the pair BEFORE the files go, or
	// systemd keeps them as `not-found failed` residue after daemon-reload. reset-failed while the units
	// are still loaded; tolerate the not-failed case (nothing to reset).
	h.resetNetworkUnitsIfFailed(ctx, automountUnit, mountUnit)

	destAuto := filepath.Join(h.unitDir, automountUnit)
	destMount := filepath.Join(h.unitDir, mountUnit)
	if err := h.run(ctx, "/usr/bin/rm", "-f", destAuto); err != nil {
		return fmt.Errorf("netmount: removing %s: %w", automountUnit, err)
	}
	if err := h.run(ctx, "/usr/bin/rm", "-f", destMount); err != nil {
		return fmt.Errorf("netmount: removing %s: %w", mountUnit, err)
	}
	_ = os.Remove(filepath.Join(h.stageDir, automountUnit))
	_ = os.Remove(filepath.Join(h.stageDir, mountUnit))
	if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
		return fmt.Errorf("netmount: daemon-reload: %w", err)
	}
	// F1 (CAMPAIGN-3): remove the now-empty mountpoint directory (the campaign accumulated 10 stub-shaped
	// leftovers). rmdir ONLY — a non-empty dir (unexpected data present) is left in place with a WARN, the
	// fail-safe; `rm -rf` is forbidden here. The host removal propagates into running guests through the
	// shared bind; a fresh guest re-binds cleanly on next start.
	h.rmdirMountpoint(ctx, where)
	h.logger.Info("netmount: removed network mount", "name", name, "where", where)
	return nil
}

// resetNetworkUnitsIfFailed reset-failed's any of the given units that is in the failed state (F2 —
// leave no `not-found failed`/start-limit residue behind a remove or a rolled-back add). Unprivileged
// is-failed read + the FELHOM_NETMOUNT reset-failed grant; every step tolerated.
func (h *SudoHostOps) resetNetworkUnitsIfFailed(ctx context.Context, units ...string) {
	for _, unit := range units {
		if h.unitFailed == nil || !h.unitFailed(ctx, unit) {
			continue
		}
		if err := h.run(ctx, h.bins.Systemctl, "reset-failed", "--", unit); err != nil {
			h.logger.Warn("netmount: reset-failed tolerated failure", "unit", unit, "err", err)
		}
	}
}

// rmdirMountpoint removes an empty network mountpoint dir under NetworkMountRoot. rmdir refuses a
// non-empty dir (the fail-safe): unexpected data is preserved and flagged, never rm -rf'd. Best-effort.
func (h *SudoHostOps) rmdirMountpoint(ctx context.Context, where string) {
	if !strings.HasPrefix(where, NetworkMountRoot+"/") {
		return // defense in depth: only ever under the bind root
	}
	if err := h.run(ctx, "/usr/bin/rmdir", where); err != nil {
		// rmdir fails on a non-empty dir — leave it (fail-safe) and flag it for the operator.
		h.logger.Warn("netmount: mountpoint dir not removed (non-empty or busy — left in place, fail-safe)",
			"where", where, "err", err)
	}
}

// MigrateNetworkUnits reconciles every marker-owned network-storage unit file on disk against a fresh
// render of its own reconstructed spec — a general template-drift reconcile (the git-sync pattern:
// content-hash compare, rewrite on drift, batched daemon-reload). It exists because a template change
// must reach ALREADY-INSTALLED units, not only future adds: the F12 fix (CAMPAIGN-3) removed the
// network-online ordering that turned every host boot with an enrolled share into a coin flip, and the
// units installed before 0.85 still carry the ordering cycle until they are rewritten. Runs at agent
// startup (before the reassert sweep) and at the head of EnsureNetworkMount. Idempotent: a unit already
// byte-identical to its fresh render is left untouched (second pass rewrites nothing). Best-effort per
// unit; one INFO line per migrated unit. Returns the count migrated.
func (h *SudoHostOps) MigrateNetworkUnits(ctx context.Context) int {
	entries, err := os.ReadDir(h.unitDir)
	if err != nil {
		h.logger.Warn("netmigrate: reading unit dir failed", "err", err)
		return 0
	}
	migrated := 0
	changed := false
	for _, e := range entries {
		if !strings.HasSuffix(e.Name(), ".mount") {
			continue // the .mount carries What/Type; the paired .automount mirrors Where
		}
		mountPath := filepath.Join(h.unitDir, e.Name())
		mountData, rerr := os.ReadFile(mountPath)
		if rerr != nil || !strings.Contains(string(mountData), netUnitMarker) {
			continue // unreadable or not one of ours
		}
		automountName := strings.TrimSuffix(e.Name(), ".mount") + ".automount"
		automountData, aerr := os.ReadFile(filepath.Join(h.unitDir, automountName))
		if aerr != nil {
			continue // a .mount with no paired .automount is malformed — not ours to guess
		}
		spec, ok := specFromNetworkUnits(string(mountData), string(automountData))
		if !ok {
			continue
		}
		freshMount := renderNetworkMountUnit(spec)
		freshAuto := renderNetworkAutomountUnit(spec)
		if contentHash(string(mountData)) == contentHash(freshMount) &&
			contentHash(string(automountData)) == contentHash(freshAuto) {
			continue // already current — the idempotent no-op
		}
		if err := h.installUnit(ctx, e.Name(), freshMount); err != nil {
			h.logger.Warn("netmigrate: rewriting mount unit failed", "unit", e.Name(), "err", err)
			continue
		}
		if err := h.installUnit(ctx, automountName, freshAuto); err != nil {
			h.logger.Warn("netmigrate: rewriting automount unit failed", "unit", automountName, "err", err)
			continue
		}
		changed = true
		migrated++
		h.logger.Info("netmigrate: migrated network-storage unit to the current template (F12: dropped the boot ordering cycle)",
			"name", spec.Name, "where", spec.Where())
	}
	if changed {
		if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
			h.logger.Warn("netmigrate: daemon-reload after migration failed", "err", err)
		}
	}
	return migrated
}

// contentHash is the SHA-256 hex of a unit file's content — the drift comparator (git-sync pattern).
func contentHash(s string) string {
	sum := sha256.Sum256([]byte(s))
	return hex.EncodeToString(sum[:])
}

// specFromNetworkUnits reconstructs the NetworkMountSpec that renders EXACTLY the given installed unit
// pair — the fresh-render input for the drift reconcile. Round-trips by construction: every field the
// render templates read is recovered (proto/server/export/where from the .mount; the SMB uid/gid+creds
// from its Options; the idle window from the .automount). A NFS spec's mapped uid never appears in a
// rendered unit, so it is irrelevant to the render and left at the container default. ok=false for a
// non-marker or unparseable pair.
func specFromNetworkUnits(mountContent, automountContent string) (NetworkMountSpec, bool) {
	proto, server, export, where, ok := parseNetworkMountUnit(mountContent)
	if !ok {
		return NetworkMountSpec{}, false
	}
	spec := NetworkMountSpec{
		Name:      strings.TrimPrefix(where, NetworkMountRoot+"/"),
		Protocol:  NetworkProtocol(proto),
		Server:    server,
		Export:    export,
		MappedUID: 1000, // container default; unused by the NFS render, overwritten below for SMB
		MappedGID: 1000,
	}
	if spec.Protocol == ProtocolSMB {
		opts := unitLineValue(mountContent, "Options=")
		if uid, ok := csvIntField(opts, "uid="); ok {
			spec.MappedUID = uid - lxcUIDOffset
		}
		if gid, ok := csvIntField(opts, "gid="); ok {
			spec.MappedGID = gid - lxcUIDOffset
		}
		spec.CredsRef = csvField(opts, "credentials=")
	}
	if idle, ok := csvIntField(unitLineValue(automountContent, "TimeoutIdleSec="), ""); ok && idle > 0 {
		spec.IdleTimeoutSec = idle
	}
	return spec, true
}

// unitLineValue returns the value after the first line beginning with prefix (e.g. "Options="), trimmed.
func unitLineValue(content, prefix string) string {
	for _, line := range strings.Split(content, "\n") {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, prefix) {
			return strings.TrimPrefix(line, prefix)
		}
	}
	return ""
}

// csvField finds the comma-separated token with the given key prefix (e.g. "credentials=") and returns
// its value. "" if absent.
func csvField(csv, key string) string {
	for _, tok := range strings.Split(csv, ",") {
		if strings.HasPrefix(tok, key) {
			return strings.TrimPrefix(tok, key)
		}
	}
	return ""
}

// csvIntField parses the int value of a comma-separated key (e.g. "uid=") — or, when key is "", parses
// the whole string as an int (for a bare value like TimeoutIdleSec's already-extracted number).
func csvIntField(csv, key string) (int, bool) {
	val := csv
	if key != "" {
		val = csvField(csv, key)
	}
	if val == "" {
		return 0, false
	}
	n, err := strconv.Atoi(strings.TrimSpace(val))
	if err != nil {
		return 0, false
	}
	return n, true
}

// ListNetworkMounts enumerates the installed network-storage units and reports per-share liveness. It
// reads the (world-readable) unit dir + /proc/mounts and TCP-probes each NAS endpoint with a short
// timeout — it NEVER stat()s the (possibly EIO/D-state) mountpoint, so a black-holed NAS cannot wedge
// this call. Best-effort: an unreadable unit dir yields an empty list.
func (h *SudoHostOps) ListNetworkMounts(ctx context.Context) ([]NetworkMountStatus, error) {
	entries, err := h.networkUnitEntries()
	if err != nil {
		return nil, err
	}
	mounts := h.mountedFSTypes()
	var out []NetworkMountStatus
	for _, e := range entries {
		st := NetworkMountStatus{
			Name:       e.name,
			Protocol:   e.proto,
			Server:     e.server,
			Export:     e.export,
			Where:      e.where,
			Configured: true,
			Mounted:    isNetworkMounted(mounts[e.where]),
			Reachable:  endpointReachable(netEndpoint(e.proto, e.server)),
		}
		st.Health = networkHealth(st.Reachable, st.Mounted)
		out = append(out, st)
	}
	return out, nil
}

// networkUnitEntry is one installed network-storage unit pair, parsed from its .mount file.
type networkUnitEntry struct {
	name, proto, server, export, where string
}

// networkUnitEntries enumerates the installed network-storage unit pairs from the (world-readable)
// unit dir — the shared core of ListNetworkMounts and ReassertNetworkAutomounts. No probing, no
// mountpoint access. Best-effort per file; an unreadable unit dir is the only error.
func (h *SudoHostOps) networkUnitEntries() ([]networkUnitEntry, error) {
	entries, err := os.ReadDir(h.unitDir)
	if err != nil {
		return nil, fmt.Errorf("netmount: reading unit dir: %w", err)
	}
	var out []networkUnitEntry
	for _, e := range entries {
		if !strings.HasSuffix(e.Name(), ".mount") {
			continue // the .mount unit carries the What/Type; the .automount mirrors Where only
		}
		data, rerr := os.ReadFile(filepath.Join(h.unitDir, e.Name()))
		if rerr != nil {
			continue
		}
		proto, server, export, where, ok := parseNetworkMountUnit(string(data))
		if !ok {
			continue // not one of ours (or a drive by-uuid mount)
		}
		out = append(out, networkUnitEntry{
			name:   strings.TrimPrefix(where, NetworkMountRoot+"/"),
			proto:  proto,
			server: server,
			export: export,
			where:  where,
		})
	}
	return out, nil
}

// mountedFSTypes maps each active mountpoint to its filesystem type (from /proc/mounts: field 2 = where,
// field 3 = fstype). Best-effort.
func (h *SudoHostOps) mountedFSTypes() map[string]string {
	out := map[string]string{}
	data, err := os.ReadFile("/proc/mounts")
	if err != nil {
		return out
	}
	for _, line := range strings.Split(string(data), "\n") {
		f := strings.Fields(line)
		if len(f) >= 3 {
			out[f[1]] = f[2]
		}
	}
	return out
}

// isNetworkMounted reports whether the recorded fstype at a mountpoint is a real network mount (not the
// autofs trigger). An idle automount shows fstype "autofs" (or nothing) → not mounted; an active mount
// shows nfs4/nfs/cifs.
func isNetworkMounted(fstype string) bool {
	switch fstype {
	case "nfs", "nfs4", "cifs":
		return true
	default:
		return false
	}
}

// NetworkEndpointReachable TCP-dials a share's NAS endpoint (NFS 2049 / SMB 445) with the short
// liveness timeout. This is the add endpoint's SYNC pre-probe (SPIKE-nas-verify Scenario E): an
// unreachable server is refused in ~2 s BEFORE any unit is installed.
func NetworkEndpointReachable(proto NetworkProtocol, server string) bool {
	return endpointReachable(netEndpoint(string(proto), server))
}

// NetworkMountedAt reports whether a REAL network filesystem (nfs/nfs4/cifs) is currently mounted at
// where, per /proc/mounts. The autofs trigger does NOT count. This is the verify job's mount-success
// truth source (SPIKE-nas-verify §8): success is judged from /proc/mounts, NEVER from readability —
// a 0700 export owned by the squashed uid gives the agent user EACCES on a perfectly good mount.
func NetworkMountedAt(where string) bool {
	data, err := os.ReadFile("/proc/mounts")
	if err != nil {
		return false
	}
	return networkMountedIn(string(data), where)
}

// networkMountedIn is the pure core of NetworkMountedAt (unit-tested against fixture tables).
func networkMountedIn(procMounts, where string) bool {
	for _, line := range strings.Split(procMounts, "\n") {
		f := strings.Fields(line)
		if len(f) >= 3 && f[1] == where && isNetworkMounted(f[2]) {
			return true
		}
	}
	return false
}

// endpointReachable TCP-dials a NAS endpoint with a short timeout (the liveness probe that never touches
// the mount). "" endpoint → not reachable.
func endpointReachable(endpoint string) bool {
	if endpoint == "" {
		return false
	}
	conn, err := net.DialTimeout("tcp", endpoint, netReachTimeout)
	if err != nil {
		return false
	}
	_ = conn.Close()
	return true
}
