package inspector

import (
	"context"
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
	"sync"
	"time"
)

// ClusterData holds all collected data from the cluster.
type ClusterData struct {
	Nodes    map[string]*NodeData // keyed by host IP
	Duration time.Duration
}

// NodeData holds collected data for a single node.
type NodeData struct {
	Node       Node
	RQLite     *RQLiteData
	Olric      *OlricData
	IPFS       *IPFSData
	DNS        *DNSData
	WireGuard  *WireGuardData
	System     *SystemData
	Network    *NetworkData
	Anyone     *AnyoneData
	Namespaces []NamespaceData // namespace instances on this node
	Errors     []string        // collection errors for this node
}

// NamespaceData holds data for a single namespace on a node.
type NamespaceData struct {
	Name          string // namespace name (from systemd unit)
	PortBase      int    // starting port of the 5-port block
	RQLiteUp      bool   // RQLite HTTP port responding
	RQLiteState   string // Raft state (Leader/Follower)
	RQLiteReady   bool   // /readyz
	OlricUp       bool   // Olric memberlist port listening
	GatewayUp     bool   // Gateway HTTP port responding
	GatewayStatus int    // HTTP status code from gateway health
	SFUUp         bool   // SFU systemd service active (optional, WebRTC)
	TURNUp        bool   // TURN systemd service active (optional, WebRTC)
}

// RQLiteData holds parsed RQLite status from a single node.
type RQLiteData struct {
	Responsive bool
	StatusRaw  string                 // raw JSON from /status
	NodesRaw   string                 // raw JSON from /nodes?nonvoters
	ReadyzRaw  string                 // raw response from /readyz
	DebugRaw   string                 // raw JSON from /debug/vars
	Status     *RQLiteStatus          // parsed /status
	Nodes      map[string]*RQLiteNode // parsed /nodes
	Readyz     *RQLiteReadyz          // parsed /readyz
	DebugVars  *RQLiteDebugVars       // parsed /debug/vars
	StrongRead bool                   // SELECT 1 with level=strong succeeded
}

// RQLiteDebugVars holds metrics from /debug/vars.
type RQLiteDebugVars struct {
	QueryErrors      uint64
	ExecuteErrors    uint64
	RemoteExecErrors uint64
	LeaderNotFound   uint64
	SnapshotErrors   uint64
	ClientRetries    uint64
	ClientTimeouts   uint64
}

// RQLiteStatus holds parsed fields from /status.
type RQLiteStatus struct {
	RaftState      string // Leader, Follower, Candidate, Shutdown
	LeaderNodeID   string // store.leader.node_id
	LeaderAddr     string // store.leader.addr
	NodeID         string // store.node_id
	Term           uint64 // store.raft.term (current_term)
	AppliedIndex   uint64 // store.raft.applied_index
	CommitIndex    uint64 // store.raft.commit_index
	FsmPending     uint64 // store.raft.fsm_pending
	LastContact    string // store.raft.last_contact (followers only)
	LastLogIndex   uint64 // store.raft.last_log_index
	LastLogTerm    uint64 // store.raft.last_log_term
	NumPeers       int    // store.raft.num_peers (string in JSON)
	LastSnapshot   uint64 // store.raft.last_snapshot_index
	Voter          bool   // store.raft.voter
	DBSize         int64  // store.sqlite3.db_size
	DBSizeFriendly string // store.sqlite3.db_size_friendly
	DBAppliedIndex uint64 // store.db_applied_index
	FsmIndex       uint64 // store.fsm_index
	Uptime         string // http.uptime
	Version        string // build.version
	GoVersion      string // runtime.GOARCH + runtime.version
	Goroutines     int    // runtime.num_goroutine
	HeapAlloc      uint64 // runtime.memory.heap_alloc (bytes)
}

// RQLiteNode holds parsed fields from /nodes response per node.
type RQLiteNode struct {
	Addr      string
	Reachable bool
	Leader    bool
	Voter     bool
	Time      float64 // response time
	Error     string
}

// RQLiteReadyz holds parsed readiness state.
type RQLiteReadyz struct {
	Ready   bool
	Store   string // "ready" or error
	Leader  string // "ready" or error
	Node    string // "ready" or error
	RawBody string
}

// OlricData holds parsed Olric status from a single node.
type OlricData struct {
	ServiceActive bool
	MemberlistUp  bool
	MemberCount   int
	Members       []string // memberlist member addresses
	Coordinator   string   // current coordinator address
	LogErrors     int      // error count in recent logs
	LogSuspects   int      // "suspect" or "Marking as failed" count
	LogFlapping   int      // rapid join/leave count
	ProcessMemMB  int      // RSS memory in MB
	RestartCount  int      // NRestarts from systemd
}

// IPFSData holds parsed IPFS status from a single node.
type IPFSData struct {
	DaemonActive     bool
	ClusterActive    bool
	SwarmPeerCount   int
	ClusterPeerCount int
	RepoSizeBytes    int64
	RepoMaxBytes     int64
	KuboVersion      string
	ClusterVersion   string
	ClusterErrors    int // peers reporting errors
	HasSwarmKey      bool
	BootstrapEmpty   bool // true if bootstrap list is empty (private swarm)
}

// DNSData holds parsed DNS/CoreDNS status from a nameserver node.
type DNSData struct {
	CoreDNSActive   bool
	CaddyActive     bool
	Port53Bound     bool
	Port80Bound     bool
	Port443Bound    bool
	CoreDNSMemMB    int
	CoreDNSRestarts int
	LogErrors       int // error count in recent CoreDNS logs
	// Resolution tests (dig results)
	SOAResolves      bool
	NSResolves       bool
	NSRecordCount    int
	WildcardResolves bool
	BaseAResolves    bool
	// TLS
	BaseTLSDaysLeft int // -1 = failed to check
	WildTLSDaysLeft int // -1 = failed to check
	// Corefile
	CorefileExists bool
}

// WireGuardData holds parsed WireGuard status from a node.
type WireGuardData struct {
	InterfaceUp   bool
	ServiceActive bool
	WgIP          string
	PeerCount     int
	Peers         []WGPeer
	MTU           int
	ListenPort    int
	ConfigExists  bool
	ConfigPerms   string // e.g. "600"
}

// WGPeer holds parsed data for a single WireGuard peer.
type WGPeer struct {
	PublicKey       string
	Endpoint        string
	AllowedIPs      string
	LatestHandshake int64 // seconds since epoch, 0 = never
	TransferRx      int64
	TransferTx      int64
	Keepalive       int
}

// SystemData holds parsed system-level data from a node.
type SystemData struct {
	Services       map[string]string // service name → status
	FailedUnits    []string          // systemd units in failed state
	MemTotalMB     int
	MemUsedMB      int
	MemFreeMB      int
	DiskTotalGB    string
	DiskUsedGB     string
	DiskAvailGB    string
	DiskUsePct     int
	UptimeRaw      string
	LoadAvg        string
	CPUCount       int
	OOMKills       int
	SwapUsedMB     int
	SwapTotalMB    int
	InodePct       int   // inode usage percentage
	ListeningPorts []int // ports from ss -tlnp
	UFWActive      bool
	ProcessUser    string // user running orama-node (e.g. "orama")
	PanicCount     int    // panic/fatal in recent logs
}

// NetworkData holds parsed network-level data from a node.
type NetworkData struct {
	InternetReachable bool
	TCPEstablished    int
	TCPTimeWait       int
	TCPRetransRate    float64 // retransmission % from /proc/net/snmp
	DefaultRoute      bool
	WGRouteExists     bool
	PingResults       map[string]bool // WG peer IP → ping success
}

// AnyoneData holds parsed Anyone relay/client status from a node.
type AnyoneData struct {
	RelayActive      bool            // orama-anyone-relay systemd service active
	ClientActive     bool            // orama-anyone-client systemd service active
	Mode             string          // "relay" or "client" (from anonrc ORPort presence)
	ORPortListening  bool            // port 9001 bound locally
	SocksListening   bool            // port 9050 bound locally (client SOCKS5)
	ControlListening bool            // port 9051 bound locally (control port)
	Bootstrapped     bool            // relay has bootstrapped to 100%
	BootstrapPct     int             // bootstrap percentage (0-100)
	Fingerprint      string          // relay fingerprint
	Nickname         string          // relay nickname
	UptimeStr        string          // uptime from control port
	ORPortReachable  map[string]bool // host IP → whether we can TCP connect to their 9001 from this node
}

// Collect gathers data from all nodes in parallel.
func Collect(ctx context.Context, nodes []Node, subsystems []string, verbose bool) *ClusterData {
	start := time.Now()
	data := &ClusterData{
		Nodes: make(map[string]*NodeData, len(nodes)),
	}

	var mu sync.Mutex
	var wg sync.WaitGroup

	for _, node := range nodes {
		wg.Add(1)
		go func(n Node) {
			defer wg.Done()
			nd := collectNode(ctx, n, subsystems, verbose)
			mu.Lock()
			data.Nodes[n.Host] = nd
			mu.Unlock()
		}(node)
	}

	wg.Wait()

	// Second pass: cross-node ORPort reachability (needs all nodes collected first)
	collectAnyoneReachability(ctx, data)

	data.Duration = time.Since(start)
	return data
}

func collectNode(ctx context.Context, node Node, subsystems []string, verbose bool) *NodeData {
	nd := &NodeData{Node: node}

	shouldCollect := func(name string) bool {
		if len(subsystems) == 0 {
			return true
		}
		for _, s := range subsystems {
			if s == name || s == "all" {
				return true
			}
		}
		return false
	}

	if shouldCollect("rqlite") {
		nd.RQLite = collectRQLite(ctx, node, verbose)
	}
	if shouldCollect("olric") {
		nd.Olric = collectOlric(ctx, node)
	}
	if shouldCollect("ipfs") {
		nd.IPFS = collectIPFS(ctx, node)
	}
	if shouldCollect("dns") && node.IsNameserver() {
		nd.DNS = collectDNS(ctx, node)
	}
	if shouldCollect("wireguard") || shouldCollect("wg") {
		nd.WireGuard = collectWireGuard(ctx, node)
	}
	if shouldCollect("system") {
		nd.System = collectSystem(ctx, node)
	}
	if shouldCollect("network") {
		nd.Network = collectNetwork(ctx, node, nd.WireGuard)
	}
	if shouldCollect("anyone") && !node.IsNameserver() {
		nd.Anyone = collectAnyone(ctx, node)
	}
	// Namespace collection — always collect if any subsystem is collected
	nd.Namespaces = collectNamespaces(ctx, node)

	return nd
}

// collectRQLite gathers RQLite data from a node via SSH.
func collectRQLite(ctx context.Context, node Node, verbose bool) *RQLiteData {
	data := &RQLiteData{}

	// Collect all endpoints in a single SSH session for efficiency.
	// We use a separator to split the outputs.
	cmd := `
SEP="===INSPECTOR_SEP==="
echo "$SEP"
curl -sf http://localhost:5001/status 2>/dev/null || echo '{"error":"unreachable"}'
echo "$SEP"
curl -sf 'http://localhost:5001/nodes?nonvoters' 2>/dev/null || echo '{"error":"unreachable"}'
echo "$SEP"
curl -sf http://localhost:5001/readyz 2>/dev/null; echo "EXIT:$?"
echo "$SEP"
curl -sf http://localhost:5001/debug/vars 2>/dev/null || echo '{"error":"unreachable"}'
echo "$SEP"
curl -sf -H 'Content-Type: application/json' 'http://localhost:5001/db/query?level=strong' -d '["SELECT 1"]' 2>/dev/null && echo "STRONG_OK" || echo "STRONG_FAIL"
`

	result := RunSSH(ctx, node, cmd)
	if !result.OK() && result.Stdout == "" {
		return data
	}

	parts := strings.Split(result.Stdout, "===INSPECTOR_SEP===")
	if len(parts) < 5 {
		return data
	}

	data.StatusRaw = strings.TrimSpace(parts[1])
	data.NodesRaw = strings.TrimSpace(parts[2])
	readyzSection := strings.TrimSpace(parts[3])
	data.DebugRaw = strings.TrimSpace(parts[4])

	// Parse /status
	if data.StatusRaw != "" && !strings.Contains(data.StatusRaw, `"error":"unreachable"`) {
		data.Responsive = true
		data.Status = parseRQLiteStatus(data.StatusRaw)
	}

	// Parse /nodes
	if data.NodesRaw != "" && !strings.Contains(data.NodesRaw, `"error":"unreachable"`) {
		data.Nodes = parseRQLiteNodes(data.NodesRaw)
	}

	// Parse /readyz
	data.Readyz = parseRQLiteReadyz(readyzSection)

	// Parse /debug/vars
	if data.DebugRaw != "" && !strings.Contains(data.DebugRaw, `"error":"unreachable"`) {
		data.DebugVars = parseRQLiteDebugVars(data.DebugRaw)
	}

	// Parse strong read
	if len(parts) > 5 {
		data.StrongRead = strings.Contains(parts[5], "STRONG_OK")
	}

	return data
}

func parseRQLiteStatus(raw string) *RQLiteStatus {
	var m map[string]interface{}
	if err := json.Unmarshal([]byte(raw), &m); err != nil {
		return nil
	}

	s := &RQLiteStatus{}

	store, _ := m["store"].(map[string]interface{})
	if store == nil {
		return s
	}

	// Raft state
	raft, _ := store["raft"].(map[string]interface{})
	if raft != nil {
		s.RaftState, _ = raft["state"].(string)
		s.Term = jsonUint64(raft, "current_term")
		s.AppliedIndex = jsonUint64(raft, "applied_index")
		s.CommitIndex = jsonUint64(raft, "commit_index")
		s.FsmPending = jsonUint64(raft, "fsm_pending")
		s.LastContact, _ = raft["last_contact"].(string)
		s.LastLogIndex = jsonUint64(raft, "last_log_index")
		s.LastLogTerm = jsonUint64(raft, "last_log_term")
		s.LastSnapshot = jsonUint64(raft, "last_snapshot_index")
		s.Voter = jsonBool(raft, "voter")

		// num_peers can be a string or number
		if np, ok := raft["num_peers"].(string); ok {
			s.NumPeers, _ = strconv.Atoi(np)
		} else if np, ok := raft["num_peers"].(float64); ok {
			s.NumPeers = int(np)
		}
	}

	// Leader info
	leader, _ := store["leader"].(map[string]interface{})
	if leader != nil {
		s.LeaderNodeID, _ = leader["node_id"].(string)
		s.LeaderAddr, _ = leader["addr"].(string)
	}

	s.NodeID, _ = store["node_id"].(string)
	s.DBAppliedIndex = jsonUint64(store, "db_applied_index")
	s.FsmIndex = jsonUint64(store, "fsm_index")

	// SQLite
	sqlite3, _ := store["sqlite3"].(map[string]interface{})
	if sqlite3 != nil {
		s.DBSize = int64(jsonUint64(sqlite3, "db_size"))
		s.DBSizeFriendly, _ = sqlite3["db_size_friendly"].(string)
	}

	// HTTP
	httpMap, _ := m["http"].(map[string]interface{})
	if httpMap != nil {
		s.Uptime, _ = httpMap["uptime"].(string)
	}

	// Build
	build, _ := m["build"].(map[string]interface{})
	if build != nil {
		s.Version, _ = build["version"].(string)
	}

	// Runtime
	runtime, _ := m["runtime"].(map[string]interface{})
	if runtime != nil {
		if ng, ok := runtime["num_goroutine"].(float64); ok {
			s.Goroutines = int(ng)
		}
		s.GoVersion, _ = runtime["version"].(string)
		if mem, ok := runtime["memory"].(map[string]interface{}); ok {
			s.HeapAlloc = jsonUint64(mem, "heap_alloc")
		}
	}

	return s
}

func parseRQLiteNodes(raw string) map[string]*RQLiteNode {
	var m map[string]interface{}
	if err := json.Unmarshal([]byte(raw), &m); err != nil {
		return nil
	}

	nodes := make(map[string]*RQLiteNode, len(m))
	for addr, v := range m {
		info, _ := v.(map[string]interface{})
		if info == nil {
			continue
		}
		n := &RQLiteNode{
			Addr:      addr,
			Reachable: jsonBool(info, "reachable"),
			Leader:    jsonBool(info, "leader"),
			Voter:     jsonBool(info, "voter"),
		}
		if t, ok := info["time"].(float64); ok {
			n.Time = t
		}
		if e, ok := info["error"].(string); ok {
			n.Error = e
		}
		nodes[addr] = n
	}
	return nodes
}

func parseRQLiteReadyz(raw string) *RQLiteReadyz {
	r := &RQLiteReadyz{RawBody: raw}

	// /readyz returns body like "[+]node ok\n[+]leader ok\n[+]store ok" with exit 0
	// or "[-]leader not ok\n..." with non-zero exit
	lines := strings.Split(raw, "\n")
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, "[+]node") {
			r.Node = "ready"
		} else if strings.HasPrefix(line, "[-]node") {
			r.Node = "not ready"
		} else if strings.HasPrefix(line, "[+]leader") {
			r.Leader = "ready"
		} else if strings.HasPrefix(line, "[-]leader") {
			r.Leader = "not ready"
		} else if strings.HasPrefix(line, "[+]store") {
			r.Store = "ready"
		} else if strings.HasPrefix(line, "[-]store") {
			r.Store = "not ready"
		}
	}

	r.Ready = r.Node == "ready" && r.Leader == "ready" && r.Store == "ready"

	// Check exit code from our appended "EXIT:$?"
	for _, line := range lines {
		if strings.HasPrefix(line, "EXIT:0") {
			r.Ready = true
		}
	}

	return r
}

func parseRQLiteDebugVars(raw string) *RQLiteDebugVars {
	var m map[string]interface{}
	if err := json.Unmarshal([]byte(raw), &m); err != nil {
		return nil
	}

	d := &RQLiteDebugVars{}

	// /debug/vars has flat keys like "store.query_errors", "store.execute_errors", etc.
	// But they can also be nested under "cmdstats" or flat depending on rqlite version.
	// Try flat numeric keys first.
	getUint := func(keys ...string) uint64 {
		for _, key := range keys {
			if v, ok := m[key]; ok {
				switch val := v.(type) {
				case float64:
					return uint64(val)
				case string:
					n, _ := strconv.ParseUint(val, 10, 64)
					return n
				}
			}
		}
		return 0
	}

	d.QueryErrors = getUint("query_errors", "store.query_errors")
	d.ExecuteErrors = getUint("execute_errors", "store.execute_errors")
	d.RemoteExecErrors = getUint("remote_execute_errors", "store.remote_execute_errors")
	d.LeaderNotFound = getUint("leader_not_found", "store.leader_not_found")
	d.SnapshotErrors = getUint("snapshot_errors", "store.snapshot_errors")
	d.ClientRetries = getUint("client_retries", "cluster.client_retries")
	d.ClientTimeouts = getUint("client_timeouts", "cluster.client_timeouts")

	return d
}

// Placeholder collectors for Phase 2

func collectOlric(ctx context.Context, node Node) *OlricData {
	data := &OlricData{}

	cmd := `
SEP="===INSPECTOR_SEP==="
echo "$SEP"
systemctl is-active orama-olric 2>/dev/null
echo "$SEP"
ss -tlnp 2>/dev/null | grep ':3322 ' | head -1
echo "$SEP"
journalctl -u orama-olric --no-pager -n 200 --since "1 hour ago" 2>/dev/null | grep -ciE '(error|ERR)' || echo 0
echo "$SEP"
journalctl -u orama-olric --no-pager -n 200 --since "1 hour ago" 2>/dev/null | grep -ciE '(suspect|marking.*(failed|dead))' || echo 0
echo "$SEP"
journalctl -u orama-olric --no-pager -n 200 --since "1 hour ago" 2>/dev/null | grep -ciE '(memberlist.*(join|leave))' || echo 0
echo "$SEP"
systemctl show orama-olric --property=NRestarts 2>/dev/null | cut -d= -f2
echo "$SEP"
ps -C olric-server -o rss= 2>/dev/null | head -1 || echo 0
`
	res := RunSSH(ctx, node, cmd)
	if !res.OK() && res.Stdout == "" {
		return data
	}

	parts := strings.Split(res.Stdout, "===INSPECTOR_SEP===")
	if len(parts) < 8 {
		return data
	}

	data.ServiceActive = strings.TrimSpace(parts[1]) == "active"
	data.MemberlistUp = strings.TrimSpace(parts[2]) != ""

	data.LogErrors = parseIntDefault(strings.TrimSpace(parts[3]), 0)
	data.LogSuspects = parseIntDefault(strings.TrimSpace(parts[4]), 0)
	data.LogFlapping = parseIntDefault(strings.TrimSpace(parts[5]), 0)
	data.RestartCount = parseIntDefault(strings.TrimSpace(parts[6]), 0)

	rssKB := parseIntDefault(strings.TrimSpace(parts[7]), 0)
	data.ProcessMemMB = rssKB / 1024

	return data
}

func collectIPFS(ctx context.Context, node Node) *IPFSData {
	data := &IPFSData{}

	cmd := `
SEP="===INSPECTOR_SEP==="
echo "$SEP"
systemctl is-active orama-ipfs 2>/dev/null
echo "$SEP"
systemctl is-active orama-ipfs-cluster 2>/dev/null
echo "$SEP"
curl -sf -X POST 'http://localhost:4501/api/v0/swarm/peers' 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('Peers') or []))" 2>/dev/null || echo -1
echo "$SEP"
curl -sf --max-time 10 'http://localhost:9094/peers' 2>/dev/null | python3 -c "import sys,json; peers=json.load(sys.stdin); print(len(peers)); errs=sum(1 for p in peers if p.get('error','')); print(errs)" 2>/dev/null || (curl -sf 'http://localhost:9094/id' 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); peers=d.get('cluster_peers',[]); print(len(peers)); print(0)" 2>/dev/null || echo -1)
echo "$SEP"
curl -sf -X POST 'http://localhost:4501/api/v0/repo/stat' 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('RepoSize',0)); print(d.get('StorageMax',0))" 2>/dev/null || echo -1
echo "$SEP"
curl -sf -X POST 'http://localhost:4501/api/v0/version' 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('Version',''))" 2>/dev/null || echo unknown
echo "$SEP"
curl -sf 'http://localhost:9094/id' 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('version',''))" 2>/dev/null || echo unknown
echo "$SEP"
test -f /opt/orama/.orama/data/ipfs/repo/swarm.key && echo yes || echo no
echo "$SEP"
curl -sf -X POST 'http://localhost:4501/api/v0/bootstrap/list' 2>/dev/null | python3 -c "import sys,json; peers=json.load(sys.stdin).get('Peers',[]); print(len(peers))" 2>/dev/null || echo -1
`
	res := RunSSH(ctx, node, cmd)
	if !res.OK() && res.Stdout == "" {
		return data
	}

	parts := strings.Split(res.Stdout, "===INSPECTOR_SEP===")
	if len(parts) < 10 {
		return data
	}

	data.DaemonActive = strings.TrimSpace(parts[1]) == "active"
	data.ClusterActive = strings.TrimSpace(parts[2]) == "active"
	data.SwarmPeerCount = parseIntDefault(strings.TrimSpace(parts[3]), -1)

	// Cluster peers: first line = count, second = errors
	clusterLines := strings.Split(strings.TrimSpace(parts[4]), "\n")
	if len(clusterLines) >= 1 {
		data.ClusterPeerCount = parseIntDefault(strings.TrimSpace(clusterLines[0]), -1)
	}
	if len(clusterLines) >= 2 {
		data.ClusterErrors = parseIntDefault(strings.TrimSpace(clusterLines[1]), 0)
	}

	// Repo stat: first line = size, second = max
	repoLines := strings.Split(strings.TrimSpace(parts[5]), "\n")
	if len(repoLines) >= 1 {
		data.RepoSizeBytes = int64(parseIntDefault(strings.TrimSpace(repoLines[0]), 0))
	}
	if len(repoLines) >= 2 {
		data.RepoMaxBytes = int64(parseIntDefault(strings.TrimSpace(repoLines[1]), 0))
	}

	data.KuboVersion = strings.TrimSpace(parts[6])
	data.ClusterVersion = strings.TrimSpace(parts[7])
	data.HasSwarmKey = strings.TrimSpace(parts[8]) == "yes"

	bootstrapCount := parseIntDefault(strings.TrimSpace(parts[9]), -1)
	data.BootstrapEmpty = bootstrapCount == 0

	return data
}

func collectDNS(ctx context.Context, node Node) *DNSData {
	data := &DNSData{
		BaseTLSDaysLeft: -1,
		WildTLSDaysLeft: -1,
	}

	// Get the domain from the node's role (e.g. "nameserver-ns1" -> we need the domain)
	// We'll discover the domain from Corefile
	cmd := `
SEP="===INSPECTOR_SEP==="
echo "$SEP"
systemctl is-active coredns 2>/dev/null
echo "$SEP"
systemctl is-active caddy 2>/dev/null
echo "$SEP"
ss -ulnp 2>/dev/null | grep ':53 ' | head -1
echo "$SEP"
ss -tlnp 2>/dev/null | grep ':80 ' | head -1
echo "$SEP"
ss -tlnp 2>/dev/null | grep ':443 ' | head -1
echo "$SEP"
ps -C coredns -o rss= 2>/dev/null | head -1 || echo 0
echo "$SEP"
systemctl show coredns --property=NRestarts 2>/dev/null | cut -d= -f2
echo "$SEP"
journalctl -u coredns --no-pager -n 100 --since "5 minutes ago" 2>/dev/null | grep -iE '(error|ERR)' | grep -cvF 'NOERROR' || echo 0
echo "$SEP"
test -f /etc/coredns/Corefile && echo yes || echo no
echo "$SEP"
DOMAIN=$(grep -oP '^\S+(?=\s*\{)' /etc/coredns/Corefile 2>/dev/null | grep -v '^\.' | head -1)
echo "DOMAIN:${DOMAIN}"
dig @127.0.0.1 SOA ${DOMAIN} +short 2>/dev/null | head -1
echo "$SEP"
dig @127.0.0.1 NS ${DOMAIN} +short 2>/dev/null
echo "$SEP"
dig @127.0.0.1 A test-wildcard.${DOMAIN} +short 2>/dev/null | head -1
echo "$SEP"
dig @127.0.0.1 A ${DOMAIN} +short 2>/dev/null | head -1
echo "$SEP"
echo | openssl s_client -servername ${DOMAIN} -connect localhost:443 2>/dev/null | openssl x509 -noout -dates 2>/dev/null | grep notAfter | cut -d= -f2
echo "$SEP"
echo | openssl s_client -servername "*.${DOMAIN}" -connect localhost:443 2>/dev/null | openssl x509 -noout -dates 2>/dev/null | grep notAfter | cut -d= -f2
`
	res := RunSSH(ctx, node, cmd)
	if !res.OK() && res.Stdout == "" {
		return data
	}

	parts := strings.Split(res.Stdout, "===INSPECTOR_SEP===")
	if len(parts) < 9 {
		return data
	}

	data.CoreDNSActive = strings.TrimSpace(parts[1]) == "active"
	data.CaddyActive = strings.TrimSpace(parts[2]) == "active"
	data.Port53Bound = strings.TrimSpace(parts[3]) != ""
	data.Port80Bound = strings.TrimSpace(parts[4]) != ""
	data.Port443Bound = strings.TrimSpace(parts[5]) != ""

	rssKB := parseIntDefault(strings.TrimSpace(parts[6]), 0)
	data.CoreDNSMemMB = rssKB / 1024
	data.CoreDNSRestarts = parseIntDefault(strings.TrimSpace(parts[7]), 0)
	data.LogErrors = parseIntDefault(strings.TrimSpace(parts[8]), 0)

	// Corefile exists
	if len(parts) > 9 {
		data.CorefileExists = strings.TrimSpace(parts[9]) == "yes"
	}

	// SOA resolution
	if len(parts) > 10 {
		soaSection := strings.TrimSpace(parts[10])
		// First line might be DOMAIN:xxx, rest is dig output
		for _, line := range strings.Split(soaSection, "\n") {
			line = strings.TrimSpace(line)
			if strings.HasPrefix(line, "DOMAIN:") {
				continue
			}
			if line != "" {
				data.SOAResolves = true
			}
		}
	}

	// NS records
	if len(parts) > 11 {
		nsSection := strings.TrimSpace(parts[11])
		count := 0
		for _, line := range strings.Split(nsSection, "\n") {
			if strings.TrimSpace(line) != "" {
				count++
			}
		}
		data.NSRecordCount = count
		data.NSResolves = count > 0
	}

	// Wildcard resolution
	if len(parts) > 12 {
		data.WildcardResolves = strings.TrimSpace(parts[12]) != ""
	}

	// Base A record
	if len(parts) > 13 {
		data.BaseAResolves = strings.TrimSpace(parts[13]) != ""
	}

	// TLS cert days left (base domain)
	if len(parts) > 14 {
		data.BaseTLSDaysLeft = parseTLSExpiry(strings.TrimSpace(parts[14]))
	}

	// TLS cert days left (wildcard)
	if len(parts) > 15 {
		data.WildTLSDaysLeft = parseTLSExpiry(strings.TrimSpace(parts[15]))
	}

	return data
}

// parseTLSExpiry parses an openssl date string and returns days until expiry (-1 on error).
func parseTLSExpiry(dateStr string) int {
	if dateStr == "" {
		return -1
	}
	// OpenSSL format: "Jan  2 15:04:05 2006 GMT"
	layouts := []string{
		"Jan  2 15:04:05 2006 GMT",
		"Jan 2 15:04:05 2006 GMT",
	}
	for _, layout := range layouts {
		if t, err := time.Parse(layout, dateStr); err == nil {
			days := int(time.Until(t).Hours() / 24)
			return days
		}
	}
	return -1
}

func collectWireGuard(ctx context.Context, node Node) *WireGuardData {
	data := &WireGuardData{}

	cmd := `
SEP="===INSPECTOR_SEP==="
echo "$SEP"
ip -4 addr show wg0 2>/dev/null | grep -oP 'inet \K[0-9.]+'
echo "$SEP"
systemctl is-active wg-quick@wg0 2>/dev/null
echo "$SEP"
cat /sys/class/net/wg0/mtu 2>/dev/null || echo 0
echo "$SEP"
sudo wg show wg0 dump 2>/dev/null
echo "$SEP"
sudo test -f /etc/wireguard/wg0.conf && echo yes || echo no
echo "$SEP"
sudo stat -c '%a' /etc/wireguard/wg0.conf 2>/dev/null || echo 000
`
	res := RunSSH(ctx, node, cmd)
	if !res.OK() && res.Stdout == "" {
		return data
	}

	parts := strings.Split(res.Stdout, "===INSPECTOR_SEP===")
	if len(parts) < 7 {
		return data
	}

	wgIP := strings.TrimSpace(parts[1])
	data.WgIP = wgIP
	data.InterfaceUp = wgIP != ""
	data.ServiceActive = strings.TrimSpace(parts[2]) == "active"
	data.MTU = parseIntDefault(strings.TrimSpace(parts[3]), 0)
	data.ConfigExists = strings.TrimSpace(parts[5]) == "yes"
	data.ConfigPerms = strings.TrimSpace(parts[6])

	// Parse wg show dump output
	// First line = interface: private-key public-key listen-port fwmark
	// Subsequent lines = peers: public-key preshared-key endpoint allowed-ips latest-handshake transfer-rx transfer-tx persistent-keepalive
	dumpLines := strings.Split(strings.TrimSpace(parts[4]), "\n")
	if len(dumpLines) >= 1 {
		ifFields := strings.Split(dumpLines[0], "\t")
		if len(ifFields) >= 3 {
			data.ListenPort = parseIntDefault(ifFields[2], 0)
		}
	}
	for _, line := range dumpLines[1:] {
		fields := strings.Split(line, "\t")
		if len(fields) < 8 {
			continue
		}
		handshake := int64(parseIntDefault(fields[4], 0))
		rx := int64(parseIntDefault(fields[5], 0))
		tx := int64(parseIntDefault(fields[6], 0))
		keepalive := parseIntDefault(fields[7], 0)

		data.Peers = append(data.Peers, WGPeer{
			PublicKey:       fields[0],
			Endpoint:        fields[2],
			AllowedIPs:      fields[3],
			LatestHandshake: handshake,
			TransferRx:      rx,
			TransferTx:      tx,
			Keepalive:       keepalive,
		})
	}
	data.PeerCount = len(data.Peers)

	return data
}

func collectSystem(ctx context.Context, node Node) *SystemData {
	data := &SystemData{
		Services: make(map[string]string),
	}

	services := []string{
		"orama-node", "orama-ipfs", "orama-ipfs-cluster",
		"orama-olric", "orama-anyone-relay", "orama-anyone-client",
		"coredns", "caddy", "wg-quick@wg0",
	}

	cmd := `SEP="===INSPECTOR_SEP==="`
	// Service statuses
	for _, svc := range services {
		cmd += fmt.Sprintf(` && echo "%s:$(systemctl is-active %s 2>/dev/null || echo inactive)"`, svc, svc)
	}
	cmd += ` && echo "$SEP"`
	cmd += ` && free -m | awk '/Mem:/{print $2","$3","$4} /Swap:/{print "SWAP:"$2","$3}'`
	cmd += ` && echo "$SEP"`
	cmd += ` && df -h / | awk 'NR==2{print $2","$3","$4","$5}'`
	cmd += ` && echo "$SEP"`
	cmd += ` && uptime -s 2>/dev/null || echo unknown`
	cmd += ` && echo "$SEP"`
	cmd += ` && nproc 2>/dev/null || echo 1`
	cmd += ` && echo "$SEP"`
	cmd += ` && uptime | grep -oP 'load average: \K.*'`
	cmd += ` && echo "$SEP"`
	cmd += ` && systemctl --failed --no-legend --no-pager 2>/dev/null | awk '{print $1}'`
	cmd += ` && echo "$SEP"`
	cmd += ` && dmesg 2>/dev/null | grep -ci 'out of memory' || echo 0`
	cmd += ` && echo "$SEP"`
	cmd += ` && df -i / 2>/dev/null | awk 'NR==2{print $5}' | tr -d '%'`
	cmd += ` && echo "$SEP"`
	cmd += ` && ss -tlnp 2>/dev/null | awk 'NR>1{split($4,a,":"); print a[length(a)]}' | sort -un`
	cmd += ` && echo "$SEP"`
	cmd += ` && sudo ufw status 2>/dev/null | head -1`
	cmd += ` && echo "$SEP"`
	cmd += ` && ps -C orama-node -o user= 2>/dev/null | head -1 || echo unknown`
	cmd += ` && echo "$SEP"`
	cmd += ` && journalctl -u orama-node --no-pager -n 500 --since "1 hour ago" 2>/dev/null | grep -ciE '(panic|fatal)' || echo 0`

	res := RunSSH(ctx, node, cmd)
	if !res.OK() && res.Stdout == "" {
		return data
	}

	parts := strings.Split(res.Stdout, "===INSPECTOR_SEP===")

	// Part 0: service statuses (before first SEP)
	if len(parts) > 0 {
		for _, line := range strings.Split(strings.TrimSpace(parts[0]), "\n") {
			line = strings.TrimSpace(line)
			if idx := strings.Index(line, ":"); idx > 0 {
				data.Services[line[:idx]] = line[idx+1:]
			}
		}
	}

	// Part 1: memory
	if len(parts) > 1 {
		for _, line := range strings.Split(strings.TrimSpace(parts[1]), "\n") {
			line = strings.TrimSpace(line)
			if strings.HasPrefix(line, "SWAP:") {
				swapParts := strings.Split(strings.TrimPrefix(line, "SWAP:"), ",")
				if len(swapParts) >= 2 {
					data.SwapTotalMB = parseIntDefault(swapParts[0], 0)
					data.SwapUsedMB = parseIntDefault(swapParts[1], 0)
				}
			} else {
				memParts := strings.Split(line, ",")
				if len(memParts) >= 3 {
					data.MemTotalMB = parseIntDefault(memParts[0], 0)
					data.MemUsedMB = parseIntDefault(memParts[1], 0)
					data.MemFreeMB = parseIntDefault(memParts[2], 0)
				}
			}
		}
	}

	// Part 2: disk
	if len(parts) > 2 {
		diskParts := strings.Split(strings.TrimSpace(parts[2]), ",")
		if len(diskParts) >= 4 {
			data.DiskTotalGB = diskParts[0]
			data.DiskUsedGB = diskParts[1]
			data.DiskAvailGB = diskParts[2]
			pct := strings.TrimSuffix(diskParts[3], "%")
			data.DiskUsePct = parseIntDefault(pct, 0)
		}
	}

	// Part 3: uptime
	if len(parts) > 3 {
		data.UptimeRaw = strings.TrimSpace(parts[3])
	}

	// Part 4: CPU count
	if len(parts) > 4 {
		data.CPUCount = parseIntDefault(strings.TrimSpace(parts[4]), 1)
	}

	// Part 5: load average
	if len(parts) > 5 {
		data.LoadAvg = strings.TrimSpace(parts[5])
	}

	// Part 6: failed units
	if len(parts) > 6 {
		for _, line := range strings.Split(strings.TrimSpace(parts[6]), "\n") {
			line = strings.TrimSpace(line)
			if line != "" {
				data.FailedUnits = append(data.FailedUnits, line)
			}
		}
	}

	// Part 7: OOM kills
	if len(parts) > 7 {
		data.OOMKills = parseIntDefault(strings.TrimSpace(parts[7]), 0)
	}

	// Part 8: inode usage
	if len(parts) > 8 {
		data.InodePct = parseIntDefault(strings.TrimSpace(parts[8]), 0)
	}

	// Part 9: listening ports
	if len(parts) > 9 {
		for _, line := range strings.Split(strings.TrimSpace(parts[9]), "\n") {
			line = strings.TrimSpace(line)
			if p := parseIntDefault(line, 0); p > 0 {
				data.ListeningPorts = append(data.ListeningPorts, p)
			}
		}
	}

	// Part 10: UFW status
	if len(parts) > 10 {
		data.UFWActive = strings.Contains(strings.TrimSpace(parts[10]), "active")
	}

	// Part 11: process user
	if len(parts) > 11 {
		data.ProcessUser = strings.TrimSpace(parts[11])
	}

	// Part 12: panic count
	if len(parts) > 12 {
		data.PanicCount = parseIntDefault(strings.TrimSpace(parts[12]), 0)
	}

	return data
}

func collectNetwork(ctx context.Context, node Node, wg *WireGuardData) *NetworkData {
	data := &NetworkData{
		PingResults: make(map[string]bool),
	}

	// Build ping commands for WG peer IPs
	var pingCmds string
	if wg != nil {
		for _, peer := range wg.Peers {
			// Extract IP from AllowedIPs (e.g. "10.0.0.2/32")
			ip := strings.Split(peer.AllowedIPs, "/")[0]
			if ip != "" && strings.HasPrefix(ip, "10.0.0.") {
				pingCmds += fmt.Sprintf(`echo "PING:%s:$(ping -c 1 -W 2 %s >/dev/null 2>&1 && echo ok || echo fail)"
`, ip, ip)
			}
		}
	}

	cmd := fmt.Sprintf(`
SEP="===INSPECTOR_SEP==="
echo "$SEP"
ping -c 1 -W 2 8.8.8.8 >/dev/null 2>&1 && echo yes || echo no
echo "$SEP"
ss -s 2>/dev/null | awk '/^TCP:/{print $0}'
echo "$SEP"
ip route show default 2>/dev/null | head -1
echo "$SEP"
ip route show 10.0.0.0/24 dev wg0 2>/dev/null | head -1
echo "$SEP"
awk '/^Tcp:/{getline; print $12" "$13}' /proc/net/snmp 2>/dev/null; sleep 1; awk '/^Tcp:/{getline; print $12" "$13}' /proc/net/snmp 2>/dev/null
echo "$SEP"
%s
`, pingCmds)

	res := RunSSH(ctx, node, cmd)
	if !res.OK() && res.Stdout == "" {
		return data
	}

	parts := strings.Split(res.Stdout, "===INSPECTOR_SEP===")

	if len(parts) > 1 {
		data.InternetReachable = strings.TrimSpace(parts[1]) == "yes"
	}

	// Parse TCP stats: "TCP:   42 (estab 15, closed 3, orphaned 0, timewait 2/0), ports 0/0/0"
	if len(parts) > 2 {
		tcpLine := strings.TrimSpace(parts[2])
		if idx := strings.Index(tcpLine, "estab "); idx >= 0 {
			rest := tcpLine[idx+6:]
			if comma := strings.IndexByte(rest, ','); comma > 0 {
				data.TCPEstablished = parseIntDefault(rest[:comma], 0)
			}
		}
		if idx := strings.Index(tcpLine, "timewait "); idx >= 0 {
			rest := tcpLine[idx+9:]
			if slash := strings.IndexByte(rest, '/'); slash > 0 {
				data.TCPTimeWait = parseIntDefault(rest[:slash], 0)
			} else if comma := strings.IndexByte(rest, ')'); comma > 0 {
				data.TCPTimeWait = parseIntDefault(rest[:comma], 0)
			}
		}
	}

	if len(parts) > 3 {
		data.DefaultRoute = strings.TrimSpace(parts[3]) != ""
	}
	if len(parts) > 4 {
		data.WGRouteExists = strings.TrimSpace(parts[4]) != ""
	}

	// Parse TCP retransmission rate from /proc/net/snmp (delta over 1 second)
	// Two snapshots: "OutSegs RetransSegs\nOutSegs RetransSegs"
	if len(parts) > 5 {
		lines := strings.Split(strings.TrimSpace(parts[5]), "\n")
		if len(lines) >= 2 {
			before := strings.Fields(lines[0])
			after := strings.Fields(lines[1])
			if len(before) >= 2 && len(after) >= 2 {
				outBefore := parseIntDefault(before[0], 0)
				retBefore := parseIntDefault(before[1], 0)
				outAfter := parseIntDefault(after[0], 0)
				retAfter := parseIntDefault(after[1], 0)
				deltaOut := outAfter - outBefore
				deltaRet := retAfter - retBefore
				if deltaOut > 0 {
					data.TCPRetransRate = float64(deltaRet) / float64(deltaOut) * 100
				}
			}
		}
	}

	// Parse ping results
	if len(parts) > 6 {
		for _, line := range strings.Split(strings.TrimSpace(parts[6]), "\n") {
			line = strings.TrimSpace(line)
			if strings.HasPrefix(line, "PING:") {
				// Format: PING:<ip>:<ok|fail>
				pingParts := strings.SplitN(line, ":", 3)
				if len(pingParts) == 3 {
					data.PingResults[pingParts[1]] = pingParts[2] == "ok"
				}
			}
		}
	}

	return data
}

func collectAnyone(ctx context.Context, node Node) *AnyoneData {
	data := &AnyoneData{
		ORPortReachable: make(map[string]bool),
	}

	cmd := `
SEP="===INSPECTOR_SEP==="
echo "$SEP"
systemctl is-active orama-anyone-relay 2>/dev/null || echo inactive
echo "$SEP"
systemctl is-active orama-anyone-client 2>/dev/null || echo inactive
echo "$SEP"
ss -tlnp 2>/dev/null | grep -q ':9001 ' && echo yes || echo no
echo "$SEP"
ss -tlnp 2>/dev/null | grep -q ':9050 ' && echo yes || echo no
echo "$SEP"
ss -tlnp 2>/dev/null | grep -q ':9051 ' && echo yes || echo no
echo "$SEP"
# Check bootstrap status from log. Fall back to notices.log.1 if current log
# is empty (logrotate may have rotated the file without signaling the relay).
BPCT=$(grep -oP 'Bootstrapped \K[0-9]+' /var/log/anon/notices.log 2>/dev/null | tail -1)
if [ -z "$BPCT" ]; then
  BPCT=$(grep -oP 'Bootstrapped \K[0-9]+' /var/log/anon/notices.log.1 2>/dev/null | tail -1)
fi
echo "${BPCT:-0}"
echo "$SEP"
# Read fingerprint (sudo needed: file is owned by debian-anon with 0600 perms)
sudo cat /var/lib/anon/fingerprint 2>/dev/null || echo ""
echo "$SEP"
# Read nickname from config
grep -oP '^Nickname \K\S+' /etc/anon/anonrc 2>/dev/null || echo ""
echo "$SEP"
# Detect relay vs client mode: check if ORPort is configured in anonrc
grep -qP '^\s*ORPort\s' /etc/anon/anonrc 2>/dev/null && echo relay || echo client
`

	res := RunSSH(ctx, node, cmd)
	if !res.OK() && res.Stdout == "" {
		return data
	}

	parts := strings.Split(res.Stdout, "===INSPECTOR_SEP===")

	if len(parts) > 1 {
		data.RelayActive = strings.TrimSpace(parts[1]) == "active"
	}
	if len(parts) > 2 {
		data.ClientActive = strings.TrimSpace(parts[2]) == "active"
	}
	if len(parts) > 3 {
		data.ORPortListening = strings.TrimSpace(parts[3]) == "yes"
	}
	if len(parts) > 4 {
		data.SocksListening = strings.TrimSpace(parts[4]) == "yes"
	}
	if len(parts) > 5 {
		data.ControlListening = strings.TrimSpace(parts[5]) == "yes"
	}
	if len(parts) > 6 {
		pct := parseIntDefault(strings.TrimSpace(parts[6]), 0)
		data.BootstrapPct = pct
		data.Bootstrapped = pct >= 100
	}
	if len(parts) > 7 {
		data.Fingerprint = strings.TrimSpace(parts[7])
	}
	if len(parts) > 8 {
		data.Nickname = strings.TrimSpace(parts[8])
	}
	if len(parts) > 9 {
		data.Mode = strings.TrimSpace(parts[9])
	}

	// If neither relay nor client is active, skip further checks
	if !data.RelayActive && !data.ClientActive {
		return data
	}

	return data
}

// collectAnyoneReachability runs a second pass to check ORPort reachability across nodes.
// Called after all nodes are collected so we know which nodes run relays.
func collectAnyoneReachability(ctx context.Context, data *ClusterData) {
	// Find all nodes running the relay (have ORPort listening)
	var relayHosts []string
	for host, nd := range data.Nodes {
		if nd.Anyone != nil && nd.Anyone.RelayActive && nd.Anyone.ORPortListening {
			relayHosts = append(relayHosts, host)
		}
	}

	if len(relayHosts) == 0 {
		return
	}

	// From each node, try to TCP connect to each relay's ORPort 9001
	var mu sync.Mutex
	var wg sync.WaitGroup

	for _, nd := range data.Nodes {
		if nd.Anyone == nil || nd.Anyone.Mode == "client" {
			continue // skip nodes without Anyone data or in client mode
		}
		wg.Add(1)
		go func(nd *NodeData) {
			defer wg.Done()

			// Build commands to test TCP connectivity to each relay
			var tcpCmds string
			for _, relayHost := range relayHosts {
				if relayHost == nd.Node.Host {
					continue // skip self
				}
				tcpCmds += fmt.Sprintf(
					`echo "ORPORT:%s:$(timeout 3 bash -c 'echo >/dev/tcp/%s/9001' 2>/dev/null && echo ok || echo fail)"
`, relayHost, relayHost)
			}

			if tcpCmds == "" {
				return
			}

			res := RunSSH(ctx, nd.Node, tcpCmds)
			if res.Stdout == "" {
				return
			}

			mu.Lock()
			defer mu.Unlock()
			for _, line := range strings.Split(res.Stdout, "\n") {
				line = strings.TrimSpace(line)
				if strings.HasPrefix(line, "ORPORT:") {
					p := strings.SplitN(line, ":", 3)
					if len(p) == 3 {
						nd.Anyone.ORPortReachable[p[1]] = p[2] == "ok"
					}
				}
			}
		}(nd)
	}
	wg.Wait()
}

func collectNamespaces(ctx context.Context, node Node) []NamespaceData {
	// Detect namespace services: orama-namespace-gateway@<name>.service
	cmd := `
SEP="===INSPECTOR_SEP==="
echo "$SEP"
systemctl list-units --type=service --all --no-pager --no-legend 'orama-namespace-gateway@*.service' 2>/dev/null | awk '{print $1}' | sed 's/orama-namespace-gateway@//;s/\.service//'
echo "$SEP"
`
	res := RunSSH(ctx, node, cmd)
	if !res.OK() && res.Stdout == "" {
		return nil
	}

	parts := strings.Split(res.Stdout, "===INSPECTOR_SEP===")
	if len(parts) < 2 {
		return nil
	}

	var names []string
	for _, line := range strings.Split(strings.TrimSpace(parts[1]), "\n") {
		line = strings.TrimSpace(line)
		if line != "" {
			names = append(names, line)
		}
	}

	if len(names) == 0 {
		return nil
	}

	// For each namespace, check its services
	// Namespace ports: base = 10000 + (index * 5)
	// offset 0=RQLite HTTP, 1=RQLite Raft, 2=Olric HTTP, 3=Olric Memberlist, 4=Gateway HTTP
	// We discover actual ports by querying each namespace's services
	var nsCmd string
	for _, name := range names {
		nsCmd += fmt.Sprintf(`
echo "NS_START:%s"
# Get gateway port from systemd or default discovery
GWPORT=$(ss -tlnp 2>/dev/null | grep 'orama-namespace-gateway@%s' | grep -oP ':\K[0-9]+' | head -1)
echo "GW_PORT:${GWPORT:-0}"
# Try common namespace port ranges (10000-10099)
for BASE in $(seq 10000 5 10099); do
  RQLITE_PORT=$((BASE))
  if curl -sf --connect-timeout 1 "http://localhost:${RQLITE_PORT}/status" >/dev/null 2>&1; then
    STATUS=$(curl -sf --connect-timeout 1 "http://localhost:${RQLITE_PORT}/status" 2>/dev/null)
    STATE=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('store',{}).get('raft',{}).get('state',''))" 2>/dev/null || echo "")
    READYZ=$(curl -sf --connect-timeout 1 "http://localhost:${RQLITE_PORT}/readyz" 2>/dev/null && echo "yes" || echo "no")
    echo "RQLITE:${BASE}:up:${STATE}:${READYZ}"
    break
  fi
done
# Check Olric memberlist
OLRIC_PORT=$((BASE + 2))
ss -tlnp 2>/dev/null | grep -q ":${OLRIC_PORT} " && echo "OLRIC:up" || echo "OLRIC:down"
# Check Gateway
GW_PORT2=$((BASE + 4))
GW_STATUS=$(curl -sf -o /dev/null -w '%%{http_code}' --connect-timeout 1 "http://localhost:${GW_PORT2}/health" 2>/dev/null || echo "0")
echo "GATEWAY:${GW_STATUS}"
echo "NS_END"
`, name, name)
	}

	nsRes := RunSSH(ctx, node, nsCmd)
	if !nsRes.OK() && nsRes.Stdout == "" {
		// Return namespace names at minimum
		var result []NamespaceData
		for _, name := range names {
			result = append(result, NamespaceData{Name: name})
		}
		return result
	}

	// Parse namespace results
	var result []NamespaceData
	var current *NamespaceData
	for _, line := range strings.Split(nsRes.Stdout, "\n") {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, "NS_START:") {
			name := strings.TrimPrefix(line, "NS_START:")
			nd := NamespaceData{Name: name}
			current = &nd
		} else if line == "NS_END" && current != nil {
			result = append(result, *current)
			current = nil
		} else if current != nil {
			if strings.HasPrefix(line, "RQLITE:") {
				// RQLITE:<base>:up:<state>:<readyz>
				rParts := strings.SplitN(line, ":", 5)
				if len(rParts) >= 5 {
					current.PortBase = parseIntDefault(rParts[1], 0)
					current.RQLiteUp = rParts[2] == "up"
					current.RQLiteState = rParts[3]
					current.RQLiteReady = rParts[4] == "yes"
				}
			} else if strings.HasPrefix(line, "OLRIC:") {
				current.OlricUp = strings.TrimPrefix(line, "OLRIC:") == "up"
			} else if strings.HasPrefix(line, "GATEWAY:") {
				code := parseIntDefault(strings.TrimPrefix(line, "GATEWAY:"), 0)
				current.GatewayStatus = code
				current.GatewayUp = code >= 200 && code < 500
			}
		}
	}

	return result
}

// Parse helper functions

func parseIntDefault(s string, def int) int {
	n, err := strconv.Atoi(s)
	if err != nil {
		return def
	}
	return n
}

// JSON helper functions

func jsonUint64(m map[string]interface{}, key string) uint64 {
	v, ok := m[key]
	if !ok {
		return 0
	}
	switch val := v.(type) {
	case float64:
		return uint64(val)
	case string:
		n, _ := strconv.ParseUint(val, 10, 64)
		return n
	case json.Number:
		n, _ := val.Int64()
		return uint64(n)
	default:
		return 0
	}
}

func jsonBool(m map[string]interface{}, key string) bool {
	v, ok := m[key]
	if !ok {
		return false
	}
	switch val := v.(type) {
	case bool:
		return val
	case string:
		return val == "true"
	default:
		return false
	}
}
