Ctrl K
ComunidadGuías…

pkg/gitops/insights/insights.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package insights

import (
	"fmt"
	"log"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/skyhook-io/radar/pkg/resourceid"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"

	"github.com/skyhook-io/radar/pkg/gitops"
	"github.com/skyhook-io/radar/pkg/gitops/diagnose"
	gitopstree "github.com/skyhook-io/radar/pkg/gitops/tree"
	"github.com/skyhook-io/radar/pkg/timeutil"
)

type Insight struct {
	Summary      Summary       `json:"summary"`
	Issues       []Issue       `json:"issues"`
	Changes      []Change      `json:"changes"`
	Plan         []PlanItem    `json:"plan"`
	History      []HistoryItem `json:"history"`
	Capabilities Capabilities  `json:"capabilities"`
	// Warnings explain non-fatal reasons the response is incomplete (RBAC
	// short-circuit, controller unreachable). UI uses this to distinguish
	// "no data" from "we couldn't fetch it".
	Warnings []string `json:"warnings,omitempty"`
	// Partial=true means desired-manifest diffs (Git vs live) aren't computed —
	// always true today. Pairs with Summary.PartialReason for the UI hint.
	Partial bool `json:"partial"`
}

type Summary struct {
	Tool                string `json:"tool"`
	Kind                string `json:"kind"`
	Namespace           string `json:"namespace"`
	Name                string `json:"name"`
	Sync                string `json:"sync,omitempty"`
	Health              string `json:"health,omitempty"`
	OperationPhase      string `json:"operationPhase,omitempty"`
	OperationMessage    string `json:"operationMessage,omitempty"`
	RawOperationMessage string `json:"rawOperationMessage,omitempty"`
	Source              string `json:"source,omitempty"`
	TargetRevision      string `json:"targetRevision,omitempty"`
	LastRevision        string `json:"lastRevision,omitempty"`
	// RollbackRevision is the revision(s) deployed by the latest successful marked
	// rollback, set only while effective auto-sync is off. Absence does not mean
	// no rollback happened.
	RollbackRevision string `json:"rollbackRevision,omitempty"`
	LastReconcile    string `json:"lastReconcile,omitempty"`
	LastApplied      string `json:"lastApplied,omitempty"`
	LastApplyAttempt string `json:"lastApplyAttempt,omitempty"`
	LastDeployed     string `json:"lastDeployed,omitempty"`
	PartialReason    string `json:"partialReason,omitempty"`
	// AutoSyncMode is the human-readable syncPolicy chip label, e.g.
	// "Manual", "Auto", "Auto · prune", "Auto · self-heal",
	// "Auto · prune · self-heal", "Suspended" (Flux), or "".
	AutoSyncMode string `json:"autoSyncMode,omitempty"`
	// Terminating mirrors metadata.deletionTimestamp. When true the
	// operations layer rejects mutating verbs with ErrResourceTerminating;
	// the UI renders the [Terminating] chip and disables action buttons.
	Terminating          bool   `json:"terminating,omitempty"`
	TerminationStartedAt string `json:"terminationStartedAt,omitempty"`
	// Finalizers names the controller(s) that must run cleanup before
	// deletion completes. When the resource is stuck Terminating, this is
	// the operator's first lead on which controller to investigate.
	Finalizers []string `json:"finalizers,omitempty"`
	// IgnoredDifferences, when non-nil, discloses the Argo Application's
	// spec.ignoreDifferences coverage — the field exclusions that suppress
	// drift from comparison (both Argo's own comparison and Radar's). Nil for
	// Flux roots and for Applications that declare no exclusions.
	IgnoredDifferences *IgnoredDifferencesSummary `json:"ignoredDifferences,omitempty"`
	// ResourceHealthMode mirrors the tree's HealthMode for Argo roots: where
	// Argo keeps per-resource health. "appTree" means the controller's
	// verdicts are not in the CR and per-resource health below, if any, is
	// Radar's own read — the UI says so.
	ResourceHealthMode string `json:"resourceHealthMode,omitempty"`
	// RemoteDestination: the current destination or recorded Argo comparison
	// is elsewhere. Radar derives nothing from same-named local resources.
	RemoteDestination bool `json:"remoteDestination,omitempty"`
	// ResourceHealthFromAPI: per-resource health came from the controller's
	// API server, so in appTree mode the verdicts shown are still Argo's.
	ResourceHealthFromAPI bool `json:"resourceHealthFromApi,omitempty"`
	// ResourceHealthAPIError: the controller's API server was asked and
	// didn't answer usefully — why, in the user's words.
	ResourceHealthAPIError string `json:"resourceHealthApiError,omitempty"`
	// ResourceHealthAPIPending: the host is still connecting to the
	// controller's API server; ask again shortly.
	ResourceHealthAPIPending bool `json:"resourceHealthApiPending,omitempty"`
	// ResourceHealthAPIRetrying: the host couldn't reach the controller's API
	// server and is retrying; ask again now and then.
	ResourceHealthAPIRetrying bool `json:"resourceHealthApiRetrying,omitempty"`
}

// IgnoredDifferencesSummary is the comparison-coverage disclosure for an Argo
// Application's spec.ignoreDifferences. RuleCount is the number of exclusion
// entries; Kinds lists the sorted unique "Group/Kind" targets (just "Kind" for
// core resources, "group/*" for a group-wide rule that omits kind).
//
// UnsupportedRuleCount is the load-bearing field: Radar evaluates only
// jsonPointer rules in its drift filter — jqPathExpressions and
// managedFieldsManagers rules are not applied — so an Application whose
// exclusions use those features can surface drift entries in Radar that
// Argo's own UI suppresses. The UI uses this count to warn about that gap.
type IgnoredDifferencesSummary struct {
	RuleCount            int      `json:"ruleCount"`
	UnsupportedRuleCount int      `json:"unsupportedRuleCount"`
	Kinds                []string `json:"kinds"`
}

type Ref struct {
	Group     string `json:"group,omitempty"`
	Kind      string `json:"kind"`
	Namespace string `json:"namespace,omitempty"`
	Name      string `json:"name"`
}

// Remediation describes a structured next-step for an Issue. Kind names the
// pattern (RemediationCreateNamespace etc.); Target names the K8s resource
// the remedy operates on (a namespace name, a resource ref, etc.). The
// frontend dispatches on Kind to render the right button + onClick handler.
//
// Invariants (per Kind):
//
//	RemediationCreateNamespace: Target MUST be a non-empty namespace name.
//
// Construct via NewCreateNamespaceRemediation rather than struct literal —
// the constructor enforces the per-Kind invariants; literal construction
// can produce a Remediation that ships to the frontend with a Kind it can't
// act on. Validate() runs the same check for callers that hold a value.
type Remediation struct {
	Kind   RemediationKind `json:"kind"`
	Target string          `json:"target,omitempty"`
	// Hint is operator-facing copy explaining what the action will do.
	// Distinct from the Issue's own Action string, which describes the
	// manual path; Hint describes what *this button* does.
	Hint string `json:"hint,omitempty"`
}

// NewCreateNamespaceRemediation constructs a validated create-namespace
// remediation. Returns nil when the namespace name is empty — every caller
// that holds the namespace name already does so because a regex captured it,
// so nil here means "the parser didn't actually capture a target" and the
// Issue should ship without a Remediation rather than with a broken one.
func NewCreateNamespaceRemediation(namespace, hint string) *Remediation {
	if namespace == "" {
		return nil
	}
	return &Remediation{Kind: RemediationCreateNamespace, Target: namespace, Hint: hint}
}

// Validate reports whether the Remediation is internally consistent for its
// Kind. Returns nil for valid values, an error describing the violation
// otherwise. Used by tests + future consumers that build remediations from
// untrusted input.
func (r *Remediation) Validate() error {
	if r == nil {
		return nil
	}
	switch r.Kind {
	case RemediationCreateNamespace:
		if r.Target == "" {
			return fmt.Errorf("create-namespace remediation requires Target (namespace name)")
		}
		return nil
	default:
		return fmt.Errorf("unknown remediation kind %q", r.Kind)
	}
}

type Issue struct {
	Severity   Severity `json:"severity"`
	Scope      Scope    `json:"scope"`
	Reason     string   `json:"reason"`
	Message    string   `json:"message"`
	RawMessage string   `json:"rawMessage,omitempty"`
	Refs       []Ref    `json:"refs,omitempty"`
	Action     string   `json:"action,omitempty"`
	// Presentation fields split optional guidance; Action remains complete for all consumers.
	ActionPrimary        string   `json:"actionPrimary,omitempty"`
	ActionSecondaryTitle string   `json:"actionSecondaryTitle,omitempty"`
	ActionSecondary      []string `json:"actionSecondary,omitempty"`
	// Hazard warns that syncing could prune every managed resource or take
	// resources over from another Application. It is kept apart from Action because the failure's Cause can
	// end in its own sync advice ("create it … so Argo creates it on sync"),
	// which the warning has to precede. Rows whose next step is itself a sync
	// also carry the hazard as their Action. Resource rows use Hazard to defer
	// generic sync advice to the warning.
	Hazard string `json:"hazard,omitempty"`
	// Remediation, when set, exposes a structured one-click fix for this
	// Issue. Frontend renders a contextual button on the failure card.
	// Nil when no automated remedy is appropriate; the Action string still
	// describes the manual path in that case.
	Remediation *Remediation `json:"remediation,omitempty"`
	// Cause is the parsed root-cause label for recognized error patterns
	// (annotation-too-large, webhook denial, RBAC). UI falls back to
	// Message when empty.
	Cause string `json:"cause,omitempty"`
	// RetryCount parsed from Argo's "(retried N times)" suffix. 0 means
	// no retry info or first attempt; the UI suppresses the "stuck"
	// indicator at 0 regardless of Stuck.
	RetryCount int `json:"retryCount,omitempty"`
	// Stuck=true when retry count crosses the "no longer transient"
	// threshold. Drives a stronger visual treatment.
	Stuck bool `json:"stuck,omitempty"`
	// Source is set on resource-scoped Issues: "controller" when the GitOps
	// controller's own per-resource health produced it, "radar" when Radar's
	// issues engine did (the controller's verdict wasn't available). The
	// Reason vocabulary differs too — Argo's "Degraded" vs the engine's
	// "CrashLoopBackOff" — but the field is the contract.
	Source string `json:"source,omitempty"`
}

type Change struct {
	Ref      Ref      `json:"ref"`
	Category Category `json:"category"`
	Sync     string   `json:"sync,omitempty"`
	Health   string   `json:"health,omitempty"`
	// HealthSource / HealthReason / HealthSeverity carry the provenance of
	// Health (see gitopstree.Node). Message holds the health message from
	// whichever source produced Health.
	HealthSource   string `json:"healthSource,omitempty"`
	HealthReason   string `json:"healthReason,omitempty"`
	HealthSeverity string `json:"healthSeverity,omitempty"`
	Message        string `json:"message,omitempty"`
	// SyncError is Argo's status.resources[].syncResult message — the last
	// sync's per-resource failure. Distinct from Message (live health) so
	// the UI can show "degraded right now" vs "last sync errored".
	SyncError    string `json:"syncError,omitempty"`
	RawSyncError string `json:"rawSyncError,omitempty"`
	// HookPhase identifies sync hook resources (PreSync / PostSync /
	// SyncFail / PostDelete); empty for non-hook resources.
	HookPhase  string `json:"hookPhase,omitempty"`
	HasDesired bool   `json:"hasDesired"`
	HasLive    bool   `json:"hasLive"`
	Missing    bool   `json:"missing,omitempty"`
	// Drift is the per-field diff between desired (parsed from the
	// kubectl.kubernetes.io/last-applied-configuration annotation) and live
	// spec. Nil when the diff isn't computable: SSA / Helm-installed
	// resources don't carry the annotation, and missing live data also nils.
	Drift *Drift `json:"drift,omitempty"`
	// RecentEvents are the newest-first events for this resource (capped),
	// inlined in the Changes view so ImagePullBackOff / FailedScheduling /
	// webhook denials are visible without opening the resource drawer.
	RecentEvents []EventSummary `json:"recentEvents,omitempty"`
	Partial      bool           `json:"partial"`
	PartialNote  string         `json:"partialNote,omitempty"`
	// Dependency marks a Flux source or dependsOn target the root consumes
	// but doesn't manage.
	Dependency bool `json:"dependency,omitempty"`
}

// Drift describes the per-field difference between desired and live spec.
// Only entries that meaningfully differ are included; unchanged fields are
// elided. The UI renders this inline so the user can see exactly what's
// drifted without having to call the Argo API or run `argocd app diff`.
type Drift struct {
	Entries []DriftEntry `json:"entries"`
	// Source identifies how the desired state was derived. Currently only
	// DriftSourceLastApplied; future SSA support may add others.
	Source DriftSource `json:"source"`
	// Truncated is set when the diff exceeded our entry cap; UI uses this
	// to show "and N more differences — open in Argo for full diff".
	Truncated bool `json:"truncated,omitempty"`
}

// DriftEntry is a single field-level difference. Path uses dot-notation
// from the root (e.g. "spec.disruption.expireAfter"); array indices appear
// as ".[0]". Desired/Live are JSON-encoded so map/array values survive the
// wire round-trip — the UI pretty-prints them.
type DriftEntry struct {
	Path    string  `json:"path"`
	Op      DriftOp `json:"op"`
	Desired string  `json:"desired,omitempty"`
	Live    string  `json:"live,omitempty"`
}

type PlanItem struct {
	Ref          Ref      `json:"ref"`
	Phase        string   `json:"phase,omitempty"`
	Wave         int      `json:"wave,omitempty"`
	WaveSet      bool     `json:"waveSet,omitempty"`
	Order        int      `json:"order"`
	Hook         string   `json:"hook,omitempty"`
	Relationship string   `json:"relationship,omitempty"`
	Status       string   `json:"status,omitempty"`
	BlockedBy    []Ref    `json:"blockedBy,omitempty"`
	Notes        []string `json:"notes,omitempty"`
}

type HistoryItem struct {
	ID          string `json:"id,omitempty"`
	Revision    string `json:"revision,omitempty"`
	DeployedAt  string `json:"deployedAt,omitempty"`
	Phase       string `json:"phase,omitempty"`
	Message     string `json:"message,omitempty"`
	RawMessage  string `json:"rawMessage,omitempty"`
	Source      string `json:"source,omitempty"`
	InitiatedBy string `json:"initiatedBy,omitempty"`
	// Rollbackable reports whether an Argo CD history entry has an ID and recorded
	// source(s), independently of auto-sync, lifecycle, in-flight operations and RBAC.
	// Nil on rows it does not apply to, such as the current operation and Flux.
	Rollbackable *bool `json:"rollbackable,omitempty"`
	// RollbackMultiSourceWarning warns when either the current spec or this entry uses plural sources.
	RollbackMultiSourceWarning bool `json:"rollbackMultiSourceWarning,omitempty"`
}

type Capabilities struct {
	Sync           bool `json:"sync"`
	Refresh        bool `json:"refresh"`
	Terminate      bool `json:"terminate"`
	Suspend        bool `json:"suspend"`
	Resume         bool `json:"resume"`
	SyncWithSource bool `json:"syncWithSource"`
	SelectiveSync  bool `json:"selectiveSync"`
	Rollback       bool `json:"rollback"`
	// ArgoDiffAvailable reports that the Argo CD resource-diff endpoint
	// (/api/argo/applications/{ns}/{name}/resource-diff) can serve a
	// desired-vs-live manifest diff for this Application's managed resources.
	// True only for Argo roots and only when the Argo CD integration is
	// connected; the frontend gates its inline/full-screen diff affordance on
	// it. Set by the insights HTTP handler, not by Build (which has no view of
	// integration connectivity).
	ArgoDiffAvailable bool `json:"argoDiffAvailable,omitempty"`
	// ArgoConfigured reports that the Argo CD integration has settings saved
	// (URL/token) even if a live connection isn't established — distinct from
	// ArgoDiffAvailable, which requires a working connection. The frontend uses
	// the pair to distinguish "not set up yet" (offer Connect) from "set up but
	// the connection is down / token rejected" (offer Reconnect), instead of
	// showing per-resource diff buttons that would fail.
	ArgoConfigured bool `json:"argoConfigured,omitempty"`
	// ArgoConnecting: configured, and the host is still making the first
	// connection; ask again shortly instead of reporting the diff as down.
	ArgoConnecting bool `json:"argoConnecting,omitempty"`
	// ArgoRetrying: configured, the connection failed in a way that may pass
	// on its own, and the host is retrying; ask again now and then.
	ArgoRetrying bool `json:"argoRetrying,omitempty"`
	// RevisionMetadataAvailable reports that the Argo CD revision-metadata
	// endpoint can resolve Git commit details (author, message, signature) for
	// this Application's deployed revisions. Same gating as ArgoDiffAvailable:
	// Argo roots with the integration connected. Set by the HTTP handler.
	RevisionMetadataAvailable bool     `json:"revisionMetadataAvailable,omitempty"`
	UnsupportedReason         string   `json:"unsupportedReason,omitempty"`
	Warnings                  []string `json:"warnings,omitempty"`
}

// Resolver supplies the cluster-state lookups insights needs beyond what's
// already on the GitOps root CR. Both methods return zero values on miss
// (nil object, nil events) — callers must tolerate misses since RBAC,
// kind-not-cached, and namespace filtering can all suppress results.
//
// A nil Resolver is valid and means "skip the enrichment that would need
// these lookups": no per-resource drift diff, no recent events. Tests and
// preview callers use nil; the production handler wires the dynamic cache.
type Resolver interface {
	// GetLive returns the live unstructured object, used to read the
	// kubectl.kubernetes.io/last-applied-configuration annotation and
	// diff it against the live spec.
	GetLive(group, kind, namespace, name string) *unstructured.Unstructured
	// RecentEvents returns up to a small handful of recent events for the
	// referenced resource, newest first. Used to surface "why is this
	// stuck" causes (image pull failure, PVC pending, webhook denial)
	// inline next to the change row instead of forcing a drill-in.
	RecentEvents(group, kind, namespace, name string) []EventSummary
	// FinalizerOwnerStatus returns a short health summary of the
	// controller responsible for clearing the given finalizer key on
	// `root`. Returns an empty string when the finalizer key isn't
	// recognized, the install namespace doesn't have matching pods, or
	// the lookup fails — callers must tolerate empty as "no signal".
	//
	// Used by detectPendingDeletion to bridge the gap between "this is
	// stuck on a finalizer" (controller-side responsibility) and *which*
	// controller and *what state it's in*. Without this signal, a stuck
	// deletion just says "investigate the controller"; with it, the
	// Issue can say "argocd-application-controller is CrashLoopBackOff
	// — start there".
	FinalizerOwnerStatus(finalizer string, root *unstructured.Unstructured) string
	// ResourceProblems returns the problems the cluster-wide issues engine has
	// already classified for one managed resource — the concrete workload "why"
	// (crashloop / oom / image-pull / unschedulable / pvc-pending) behind an
	// Argo "Degraded"/"Missing" rollup, so the detail page can answer it inline
	// instead of sending the operator to the drawer. Empty when nothing is
	// classified OR the lookup is unavailable; callers must NOT treat empty as
	// "healthy" (they keep the generic "go inspect" guidance in that case).
	ResourceProblems(group, kind, namespace, name string) []ResourceProblem
}

// ResourceProblem is a flat, vocabulary-neutral projection of one issue the
// cluster-wide issues engine classified for a managed resource. The insights
// package stays free of the issuesapi wire model — the host (which owns the
// issues engine) maps issuesapi.Issue onto these plain strings.
type ResourceProblem struct {
	Reason   string // e.g. CrashLoopBackOff
	Message  string // human-readable detail
	Category string // e.g. crashloop, oom_killed
	Severity string // critical | warning
}

// EventSummary is a compact projection of a corev1.Event for UI display.
// We strip everything that's not useful at a glance — count + type + reason
// + message + age is what an operator scans first.
type EventSummary struct {
	Type               string `json:"type"`            // Normal | Warning
	Reason             string `json:"reason"`          // FailedScheduling, ImagePullBackOff, etc.
	Message            string `json:"message"`         // human-readable detail
	Count              int32  `json:"count,omitempty"` // event aggregation count (>1 indicates repetition)
	LastTimestamp      string `json:"lastTimestamp"`   // RFC3339 of most recent occurrence
	ReportingComponent string `json:"reportingComponent,omitempty"`
}

// Build derives the detail-page insight for an Argo CD or Flux root. A root
// that is neither gets only its identity: no tool, operations or history.
func Build(root *unstructured.Unstructured, resourceTree *gitopstree.ResourceTree, resolver Resolver) Insight {
	tool := detectTool(root)
	if tool == "" {
		if root == nil {
			return Insight{}
		}
		return Insight{Summary: Summary{Kind: root.GetKind(), Namespace: root.GetNamespace(), Name: root.GetName()}}
	}
	out := Insight{
		Summary:      buildSummary(root, tool),
		Issues:       buildIssues(root, resourceTree, tool, resolver),
		Changes:      buildChanges(root, resourceTree, tool, resolver),
		Plan:         buildPlan(root, resourceTree, tool),
		History:      BuildHistory(root),
		Capabilities: buildCapabilities(root, tool),
		Partial:      true,
	}
	out.Summary.PartialReason = "Radar shows the controller's drift assessment plus a per-resource field diff and recent events (when available). For the canonical line-by-line diff against Git, use the Argo CD UI or `argocd app diff`."
	if resourceTree != nil {
		out.Summary.ResourceHealthMode = string(resourceTree.HealthMode)
		// An Application with no destination is invalid rather than remote;
		// Argo's InvalidSpecError condition already says so.
		out.Summary.RemoteDestination = resourceTree.RemoteDestination && (tool != "argocd" || gitops.HasArgoDestination(root))
		out.Summary.ResourceHealthFromAPI = resourceTree.HealthFromAPI
		out.Summary.ResourceHealthAPIError = resourceTree.HealthAPIError
		out.Summary.ResourceHealthAPIPending = resourceTree.HealthAPIPending
		out.Summary.ResourceHealthAPIRetrying = resourceTree.HealthAPIRetrying
	}
	return out
}

// pluralizeResourcesAre renders "resource is" for one and "resources are" for
// any other count, so the degraded-resources message reads grammatically.
func pluralizeResourcesAre(n int) string {
	if n == 1 {
		return "resource is"
	}
	return "resources are"
}

func BuildHistory(root *unstructured.Unstructured) []HistoryItem {
	tool := detectTool(root)
	if tool == "" {
		return nil
	}
	return buildHistory(root, tool)
}

// detectTool returns "argocd", "fluxcd", or "" for a root that is neither.
func detectTool(root *unstructured.Unstructured) string {
	return gitops.ToolOf(root)
}

func buildSummary(root *unstructured.Unstructured, tool string) Summary {
	s := Summary{
		Tool:      tool,
		Kind:      root.GetKind(),
		Namespace: root.GetNamespace(),
		Name:      root.GetName(),
	}
	// Lifecycle: zero-cost to surface even on healthy resources, removes
	// a class of "the page lies — clicking buttons does nothing" bugs
	// where the resource is actually pending deletion.
	if dt := root.GetDeletionTimestamp(); dt != nil && !dt.IsZero() {
		s.Terminating = true
		s.TerminationStartedAt = dt.UTC().Format(time.RFC3339)
		s.Finalizers = root.GetFinalizers()
	}
	if tool == "argocd" {
		s.Sync, _, _ = unstructured.NestedString(root.Object, "status", "sync", "status")
		s.Health, _, _ = unstructured.NestedString(root.Object, "status", "health", "status")
		s.OperationPhase, _, _ = unstructured.NestedString(root.Object, "status", "operationState", "phase")
		opMessage, _, _ := unstructured.NestedString(root.Object, "status", "operationState", "message")
		s.OperationMessage, s.RawOperationMessage = diagnose.CleanArgoControllerMessageWithRaw(opMessage)
		s.TargetRevision, _, _ = unstructured.NestedString(root.Object, "status", "sync", "revision")
		s.LastRevision, _, _ = unstructured.NestedString(root.Object, "status", "operationState", "syncResult", "revision")
		if root.GetKind() == "Application" {
			value, _, _ := unstructured.NestedString(root.Object, "status", "reconciledAt")
			if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil && !parsed.IsZero() {
				s.LastReconcile = parsed.UTC().Format(time.RFC3339Nano)
			}
		}
		source, _, _ := unstructured.NestedMap(root.Object, "spec", "source")
		if len(source) == 0 {
			sources, _, _ := unstructured.NestedSlice(root.Object, "spec", "sources")
			if len(sources) > 0 {
				source, _ = sources[0].(map[string]any)
			}
		}
		s.Source = joinNonEmpty(gitops.StringValue(source["repoURL"]), gitops.StringValue(source["path"]), gitops.StringValue(source["chart"]))
		if !gitops.ArgoAutoSyncEnabled(root) {
			s.RollbackRevision, _ = gitops.ArgoSuccessfulRollback(root)
		}
		s.AutoSyncMode = describeArgoAutoSync(root)
		s.IgnoredDifferences = summarizeArgoIgnoreDifferences(root)
		return s
	}
	status := fluxStatus(root)
	s.Sync = status.sync
	s.Health = status.health
	s.TargetRevision, _, _ = unstructured.NestedString(root.Object, "status", "lastAttemptedRevision")
	s.LastRevision, _, _ = unstructured.NestedString(root.Object, "status", "lastAppliedRevision")
	if at, succeeded := gitops.FluxLastApply(root); succeeded {
		s.LastApplied = at
	} else {
		s.LastApplyAttempt = at
	}
	if release := gitops.FluxDeployedRelease(root); release != nil && release.ChartVersion != "" {
		s.LastRevision = release.ChartVersion
		s.LastDeployed = release.LastDeployed
	}
	if sources := gitops.FluxSourceReferences(root); len(sources) > 0 {
		s.Source = sources[0].Kind + "/" + sources[0].Name
	}

	if suspended, _, _ := unstructured.NestedBool(root.Object, "spec", "suspend"); suspended {
		s.AutoSyncMode = "Suspended"
	} else {
		s.AutoSyncMode = "Auto"
	}
	return s
}

// describeArgoAutoSync formats spec.syncPolicy.automated into a chip label.
// Empty when the field can't be read; "Manual" when automated is absent or
// switched off with `enabled: false`.
func describeArgoAutoSync(root *unstructured.Unstructured) string {
	// An ApplicationSet has no sync policy of its own. Its spec.syncPolicy
	// governs how generated Applications are created and deleted, not how
	// anything syncs, so it never carries `automated` - reading it here would
	// report every ApplicationSet as "Manual". The policy that does decide
	// auto-sync lives on spec.template.spec and belongs to what it generates.
	if root.GetKind() == "ApplicationSet" {
		return ""
	}
	if !gitops.ArgoAutoSyncEnabled(root) {
		return "Manual"
	}
	automated, _, _ := unstructured.NestedMap(root.Object, "spec", "syncPolicy", "automated")
	parts := []string{"Auto"}
	if v, ok := automated["prune"].(bool); ok && v {
		parts = append(parts, "prune")
	}
	if v, ok := automated["selfHeal"].(bool); ok && v {
		parts = append(parts, "self-heal")
	}
	return strings.Join(parts, " · ")
}

func buildIssues(root *unstructured.Unstructured, resourceTree *gitopstree.ResourceTree, tool string, resolver Resolver) []Issue {
	var out []Issue
	// Argo's app-level health is its verdict on every resource it assesses.
	// Radar's own reads (topology fill, degraded counts) must not contradict
	// a Healthy app with Issues. Flux roots carry no status.health, so this
	// never gates them.
	appHealth, _, _ := unstructured.NestedString(root.Object, "status", "health", "status")
	appHealthy := appHealth == "Healthy"
	// Pending deletion is appended first; the severity-stable sort below
	// may reorder by severity-rank (e.g. a critical operation failure can
	// land above an alert-tier lifecycle issue). The user-facing
	// "lifecycle dominates" contract is enforced by the *frontend*
	// (GitOpsIssuesBand extracts scope=lifecycle to render as a banner
	// above all other issues), so the array order here is incidental.
	// If a future caller renders Issues in raw order without the banner
	// extraction, that caller must hoist the lifecycle Issue itself.
	if pd := detectPendingDeletion(root, resolver); pd != nil {
		out = append(out, *pd)
	}
	// suppressedRefs tracks resources whose own Issue is causally derivative of
	// a parent operation failure (e.g. a Missing resource issue is just the
	// per-resource view of an apply that already failed at the operation
	// level). Hiding these prevents the user from seeing the same root cause
	// rendered in three different forms.
	suppressedRefs := map[string]bool{}
	suppressedNamespaces := map[string]bool{}
	// Only the failed-attempt condition duplicates a failed operation with a
	// message; an empty-render guard can describe a different revision.
	operationFailed := false
	if tool == "argocd" {
		syncHazard := argoSyncHazardAction(root)
		opMessage, _, _ := unstructured.NestedString(root.Object, "status", "operationState", "message")
		if phase, _, _ := unstructured.NestedString(root.Object, "status", "operationState", "phase"); phase == "Failed" || phase == "Error" {
			operationFailed = true
			msg, rawMsg := diagnose.CleanArgoControllerMessageWithRaw(opMessage)
			parsed := diagnose.ParseArgoOperationError(msg)
			createNamespaceSet := gitops.ArgoCreateNamespaceEnabled(root)
			parsed = diagnose.WithCreateNamespaceState(parsed, createNamespaceSet)
			action := fallback(parsed.Action, "Open Activity for operation details.")
			if !gitops.IsInClusterDestination(root) || gitops.ArgoAppliesElsewhere(root) {
				var remoteAction string
				if parsed, remoteAction = diagnose.WithoutLocalRemediation(parsed, createNamespaceSet); remoteAction != "" {
					action = remoteAction
				}
			}
			issue := Issue{
				Severity:             SeverityCritical,
				Scope:                ScopeOperation,
				Reason:               phase,
				Message:              fallback(msg, "Last sync operation failed"),
				RawMessage:           rawMsg,
				Action:               action,
				ActionPrimary:        parsed.ActionPrimary,
				ActionSecondaryTitle: parsed.ActionSecondaryTitle,
				ActionSecondary:      parsed.ActionSecondary,
				Cause:                parsed.Cause,
				RetryCount:           parsed.RetryCount,
				Stuck:                parsed.Stuck,
				Remediation:          remediationFromParsed(parsed),
			}
			// The one-click fix retries the sync, which would now prune
			// everything or take over shared resources.
			if syncHazard != "" {
				issue.Hazard = syncHazard
				issue.Remediation = nil
			}
			if parsed.AffectedKind != "" && parsed.AffectedName != "" {
				ref := Ref{Kind: parsed.AffectedKind, Name: parsed.AffectedName}
				issue.Refs = []Ref{ref}
				// argoAffectedRefRE captures Kind + Name only — Argo's operation
				// message doesn't include the resource namespace. The per-resource
				// pass below carries a populated namespace, so we key the
				// suppression set by kind+name only; using refKey here would
				// silently fail to match any namespaced resource.
				suppressedRefs[suppressionKey(ref)] = true
			}
			// When the remediation pins the root cause to a single missing
			// namespace, every resource targeting that namespace is just a
			// downstream symptom — suppress them in the per-resource pass.
			if parsed.RemediationKind == diagnose.RemediationCreateNamespace {
				suppressedNamespaces[parsed.RemediationTarget] = true
			}
			out = append(out, issue)
		} else if blocked := argoSyncWindowBlockedIssue(phase, root); blocked != nil {
			out = append(out, *blocked)
		} else if phase == "Running" {
			out = append(out, Issue{Severity: SeverityInfo, Scope: ScopeOperation, Reason: "Running", Message: "A sync operation is currently running.", Action: "Wait for completion or terminate if it is stuck."})
		} else if mismatch := DetectArgoRollbackRevisionMismatch(root); mismatch != nil {
			out = append(out, *mismatch)
		} else if argoEmptyRenderGuardActive(root) {
			// The guard condition below already explains why the app stays
			// OutOfSync; a drift row would misread it and suggest a sync that
			// prunes everything.
		} else if stuck := detectStuckDriftLoop(root); stuck != nil {
			// Stuck-drift-loop detector: the user's "this is stuck forever and
			// nothing tells me why" case. Argo reports the last sync as
			// Succeeded but the app is still OutOfSync, auto-sync is on, and
			// reconciledAt is recent. Something is mutating the resource
			// after each apply (controller defaults, conversion webhook,
			// another operator). Without this issue, the only signal is the
			// OutOfSync badge — which the user has been staring at for hours.
			out = append(out, *stuck)
		} else if drift := detectManualDriftWithoutAutoSync(root, syncHazard); drift != nil {
			// Manual drift without auto-sync: app is OutOfSync but auto-sync
			// is off, so nothing will reconcile until a human clicks Sync.
			// Common operator confusion: "I see drift, why isn't anything
			// happening?" Answer: nothing is *supposed* to happen
			// automatically.
			out = append(out, *drift)
		} else if drift := detectAutoDriftSelfHealOff(root, syncHazard); drift != nil {
			// Auto-sync on but self-heal off: Argo deploys new Git revisions
			// yet won't correct live drift, so an OutOfSync app sits drifted
			// with nothing to reconcile it. Same "why isn't this fixing
			// itself?" confusion as manual mode, different cause.
			out = append(out, *drift)
		}
		// Argo Application status.conditions are how the controller signals
		// app-level problems that aren't tied to a specific operation
		// (ComparisonError, InvalidSpecError, OrphanedResourceWarning, …) —
		// the answers to "why is this app broken" when no operation has run.
		// Only the failed-attempt SyncError duplicates the operation failure card.
		for _, ci := range argoApplicationConditions(root, syncHazard) {
			if operationFailed && ci.Reason == argoSyncErrorConditionType && diagnose.IsArgoFailedAttemptCondition(ci.Message, opMessage) {
				continue
			}
			out = append(out, ci)
		}
		// buildIssues uses change data only for resource-level issue
		// detection — the per-resource diff/events live on the Change
		// objects emitted by buildChanges. Pass nil resolver here to skip
		// the (unused) drift computation in this code path.
		for _, change := range argoResourceChanges(root, resourceTree, nil) {
			// Suppress a resource issue when its kind/name match a resource
			// already named in the operation failure — same root cause, no
			// value in showing it twice. Also suppress every resource in a
			// namespace named by a structured remediation (the missing
			// namespace IS the cause; per-resource Missing rows are noise).
			if suppressedRefs[suppressionKey(change.Ref)] {
				continue
			}
			if change.Ref.Namespace != "" && suppressedNamespaces[change.Ref.Namespace] {
				continue
			}
			if change.Missing {
				issue := resourceHealthIssue(change, nil)
				if syncHazard != "" && change.Sync != "Synced" {
					issue.Action = syncHazard
					issue.Hazard = syncHazard
				} else if revision, _ := gitops.ArgoSuccessfulRollback(root); revision != "" && change.Sync != "Synced" && !gitops.ArgoAutoSyncEnabled(root) {
					issue.Action = "Auto-sync is off after a rollback to " + revision + ". Syncing deploys the current revision, which can recreate it and undo the rollback."
				}
				out = append(out, issue)
			} else if change.Health == "Degraded" || change.Health == "Missing" {
				// Argo calling the app Healthy is its verdict on every resource
				// it assesses; Radar's own read of a resource must not contradict
				// that with an Issue (the node chip still shows it).
				if change.HealthSource == string(gitopstree.HealthSourceRadar) && appHealthy {
					continue
				}
				// The issues engine reads this cluster; for an app deploying
				// elsewhere its answer would describe an unrelated local
				// object, so the cause bridge stays off.
				causeResolver := resolver
				if resourceTree != nil && resourceTree.RemoteDestination {
					causeResolver = nil
				}
				out = append(out, resourceHealthIssue(change, causeResolver))
			}
			// A resource that is merely OutOfSync (healthy, just drifted) gets
			// no Issue. One issue per drifted resource restates the Resources
			// table one-for-one, and on a broadly-drifted app (fresh deploy,
			// bumped chart) it buries the genuinely diagnostic issues under
			// dozens of identical "X is out of sync / run sync" rows. The
			// app-level sync badge + count own "how much has drifted", the
			// table owns "which", and the ManualDrift / StuckDriftLoop
			// detectors own the actionable "why isn't this reconciling" cases.
		}
		// Argo CD 3.x no longer persists per-resource health in the
		// Application CR by default (controller.resource.health.persist=false,
		// status.resourceHealthSource=appTree), so status.resources[] carries
		// no health.status for ANY kind even though Argo's own health check
		// (built-in Lua for ClusterSecretStore and friends included) rolled
		// the app up to Degraded. The per-resource pass above then has nothing
		// to point at. Offer the loudest recent Warning event as a lead rather
		// than leaving the badge unexplained. Only for an app deploying to
		// THIS cluster: events describe local objects, and a same-named local
		// resource is not the remote one. An informational Running/drift row
		// is not an explanation and must not suppress this; a failed operation
		// or a per-resource Issue is. A warning-tier per-resource finding
		// doesn't count as explained either, but it already names a resource;
		// stacking an events lead about the same app on top of it is noise.
		if !degradedResourcesExplained(out) && !hasResourceFinding(out) && gitops.IsInClusterDestination(root) && !gitops.ArgoAppliesElsewhere(root) {
			if health, _, _ := unstructured.NestedString(root.Object, "status", "health", "status"); health == "Degraded" {
				if iss := degradedResourceFromEvents(root, resolver); iss != nil {
					out = append(out, *iss)
				}
			}
		}
	} else {
		for _, c := range conditions(root) {
			if c.status == "False" && (c.typ == "Ready" || c.typ == "Healthy" || c.typ == "Released" || c.typ == "TestSuccess") {
				issue := Issue{Severity: SeverityCritical, Scope: ScopeCondition, Reason: fallback(c.reason, c.typ), Message: fallback(c.message, c.typ+" is false"), Action: diagnose.ActionForFluxReason(c.reason)}
				out = append(out, attachFluxBlocker(issue, root, resourceTree))
			}
			if c.status == "True" && c.typ == "Stalled" {
				issue := Issue{Severity: SeverityCritical, Scope: ScopeCondition, Reason: fallback(c.reason, "Stalled"), Message: fallback(c.message, "Reconciliation is stalled"), Action: diagnose.ActionForFluxReason(c.reason)}
				out = append(out, attachFluxBlocker(issue, root, resourceTree))
			}
			if c.status == "True" && c.typ == "Reconciling" {
				out = append(out, Issue{Severity: SeverityInfo, Scope: ScopeCondition, Reason: fallback(c.reason, "Reconciling"), Message: fallback(c.message, "Reconciliation is in progress")})
			}
		}
		out = append(out, fluxSourceWarnings(resourceTree, out)...)
	}
	// The events lead is a pointer, so the count stays next to it; a
	// per-resource finding of any tier already names a resource, so the
	// count would only restate it.
	if resourceTree != nil && !appHealthy && !degradedResourcesExplained(out) && !hasResourceFinding(out) {
		degraded := resourceTree.Summary.Degraded
		if tool == "fluxcd" {
			degraded = unexplainedFluxDegraded(resourceTree, out)
		}
		if degraded > 0 {
			message := fmt.Sprintf("%d managed %s degraded", degraded, pluralizeResourcesAre(degraded))
			if len(resourceTree.Warnings) > 0 {
				message += " among the resources shown"
			}
			out = append(out, Issue{Severity: SeverityWarning, Scope: ScopeTree, Reason: "DegradedResources", Message: message, Action: "Use the graph or Resources tab to inspect affected resources."})
		}
	}
	// Dedup by (scope, reason, message) — Flux carries the same failure
	// reason in multiple status.conditions slots (Released=False *and*
	// Reconciling=False both report "UpgradeFailed" with the identical
	// message), which produced visible duplicate rows in the UI panel.
	// Argo similarly can repeat a condition across reconcile attempts.
	// Keep the first occurrence (which preserves whatever ordering the
	// detector chain already chose).
	out = dedupeIssues(out)
	sort.SliceStable(out, func(i, j int) bool { return severityRank(out[i].Severity) < severityRank(out[j].Severity) })
	return out
}

// degradedResourceFromEvents picks, from the Application's declared managed
// resources, the one with the loudest recent Warning event, as a lead when
// nothing else identified why the app is Degraded. Events are not a health
// verdict (a recovered resource can keep a repeated Warning inside the event
// TTL), so the Issue is warning-tier, says "possible cause", and never counts
// as having explained the app's health — the degraded-resources summary
// still shows next to it. Returns nil when no managed resource has a Warning
// event, or when resolver is nil (tests, and any caller that opts out of
// live enrichment).
func degradedResourceFromEvents(root *unstructured.Unstructured, resolver Resolver) *Issue {
	if resolver == nil {
		return nil
	}
	raw, _, _ := unstructured.NestedSlice(root.Object, "status", "resources")
	var (
		best      Ref
		bestEvent EventSummary
		// -1, not 0: a real, single-occurrence Warning event commonly reports
		// Count == 0 on modern clusters (events.k8s.io/v1 only sets a count at
		// all once an event has repeated into a series) — starting the
		// sentinel at 0 would make that genuine signal indistinguishable from
		// "no Warning event found," and the function would silently return no
		// issue for exactly the first-occurrence case it exists to catch.
		bestCount int32 = -1
	)
	for _, item := range raw {
		m, ok := item.(map[string]any)
		if !ok {
			continue
		}
		ref := Ref{
			Group:     gitops.StringValue(m["group"]),
			Kind:      gitops.StringValue(m["kind"]),
			Namespace: gitops.StringValue(m["namespace"]),
			Name:      gitops.StringValue(m["name"]),
		}
		if ref.Kind == "" || ref.Name == "" {
			continue
		}
		for _, ev := range resolver.RecentEvents(ref.Group, ref.Kind, ref.Namespace, ref.Name) {
			if ev.Type != "Warning" {
				continue
			}
			if ev.Count > bestCount {
				bestCount = ev.Count
				best = ref
				bestEvent = ev
			}
		}
	}
	if bestCount < 0 {
		return nil
	}
	return &Issue{
		Severity:   SeverityWarning,
		Scope:      ScopeResource,
		Reason:     "PossibleCause",
		Message:    fmt.Sprintf("%s %s has recent Warning events", best.Kind, best.Name),
		RawMessage: bestEvent.Message,
		Refs:       []Ref{best},
		Action:     "Open the resource drawer to confirm.",
		Cause:      fallback(bestEvent.Message, bestEvent.Reason),
	}
}

// resourceHealthIssue turns a Degraded/Missing managed resource into an
// Issue. When the controller produced the health, the Issue speaks Argo's
// vocabulary ("Deployment web is Degraded") and the issues engine is asked
// for the concrete cause behind it. When Radar's engine produced the health
// (the controller's verdict wasn't available), the Issue IS the engine's
// finding — its reason, message and severity — and says so via Source, so a
// warning-tier finding never gets promoted to a critical Issue by the Argo
// path's framing.
func resourceHealthIssue(change Change, resolver Resolver) Issue {
	if change.Missing {
		action := "Sync the application to create the resource."
		if change.Sync == "Synced" {
			action = "Refresh the application to check its status."
		}
		return Issue{
			Severity: SeverityWarning,
			Scope:    ScopeResource,
			Reason:   "Missing",
			Message:  fmt.Sprintf("%s %s is not present in the cluster", change.Ref.Kind, change.Ref.Name),
			Refs:     []Ref{change.Ref},
			Action:   action,
			Source:   string(gitopstree.HealthSourceRadar),
		}
	}
	if change.HealthSource == string(gitopstree.HealthSourceRadar) && change.HealthReason != "" {
		severity := SeverityWarning
		if change.HealthSeverity == "critical" {
			severity = SeverityCritical
		}
		message := fmt.Sprintf("%s %s is %s", change.Ref.Kind, change.Ref.Name, change.Health)
		if change.Message != "" {
			message = fmt.Sprintf("%s %s: %s", change.Ref.Kind, change.Ref.Name, change.Message)
		}
		return Issue{
			Severity: severity,
			Scope:    ScopeResource,
			Reason:   change.HealthReason,
			Message:  message,
			Refs:     []Ref{change.Ref},
			Action:   "Open the resource drawer for events, logs, and YAML.",
			Source:   change.HealthSource,
		}
	}
	// Radar's own topology read with no classified reason is a live-state
	// observation, not a verdict: warning tier. The controller's Degraded /
	// Missing is the verdict and stays critical.
	severity := SeverityCritical
	if change.HealthSource == string(gitopstree.HealthSourceRadar) {
		severity = SeverityWarning
	}
	iss := Issue{
		Severity: severity,
		Scope:    ScopeResource,
		Reason:   change.Health,
		Message:  fmt.Sprintf("%s %s is %s", change.Ref.Kind, change.Ref.Name, change.Health),
		Refs:     []Ref{change.Ref},
		Action:   "Open the resource drawer for events, logs, and YAML.",
		Source:   fallback(change.HealthSource, string(gitopstree.HealthSourceController)),
	}
	// Bridge to the cluster-wide issues engine for the concrete workload
	// cause (crashloop / oom / image-pull / unschedulable …) behind Argo's
	// coarse "Degraded"/"Missing". Empty result keeps the generic guidance
	// above — it never implies the resource is healthy.
	if resolver != nil {
		if cause := resourceProblemCause(resolver.ResourceProblems(change.Ref.Group, change.Ref.Kind, change.Ref.Namespace, change.Ref.Name)); cause != "" {
			iss.Cause = cause
		}
	}
	return iss
}

// degradedResourcesExplained reports whether the Issues so far already
// account for degraded managed resources: a critical per-resource Issue
// names one, a failed sync operation is the upstream cause of all of them.
// Informational rows (sync Running) and drift detectors (StuckDriftLoop,
// ManualDrift — sync signals, not health) explain nothing, and neither
// does the warning-tier events lead — it points, it doesn't conclude.
// hasResourceFinding reports a per-resource Issue that names a problem on a
// specific resource — any tier, but not the Warning-event lead, which only
// points at one.
func hasResourceFinding(issues []Issue) bool {
	for _, iss := range issues {
		if iss.Scope == ScopeResource && iss.Reason != "PossibleCause" {
			return true
		}
	}
	return false
}

func degradedResourcesExplained(issues []Issue) bool {
	for _, iss := range issues {
		if iss.Scope == ScopeResource && iss.Severity == SeverityCritical {
			return true
		}
		if iss.Scope == ScopeOperation && (iss.Reason == "Failed" || iss.Reason == "Error") {
			return true
		}
	}
	return false
}

// resourceProblemCause renders a single cause line from the workload problems
// the issues engine classified for a managed resource — the worst (critical
// over warning, else first) one's detail. Returns "" for no problems so the
// caller keeps its generic guidance.
func resourceProblemCause(problems []ResourceProblem) string {
	best, ok := WorstResourceProblem(problems)
	if !ok {
		return ""
	}
	return fallback(best.Message, best.Reason)
}

// WorstResourceProblem picks the problem to speak for a resource: critical
// over warning, else the first. False when there are none.
func WorstResourceProblem(problems []ResourceProblem) (ResourceProblem, bool) {
	if len(problems) == 0 {
		return ResourceProblem{}, false
	}
	best := problems[0]
	for _, p := range problems[1:] {
		if best.Severity != "critical" && p.Severity == "critical" {
			best = p
		}
	}
	return best, true
}

// argoSyncWindowBlockedIssue reports an operation the Argo CD controller is
// holding for a sync window: it stays Running, with the message "Sync
// operation blocked by sync window", until a window allows it, so "currently
// running" would misdescribe it. A termination requested meanwhile waits for
// the window too, since the controller only acts on the operation then.
func argoSyncWindowBlockedIssue(phase string, root *unstructured.Unstructured) *Issue {
	if phase != "Running" && phase != "Terminating" {
		return nil
	}
	message, _, _ := unstructured.NestedString(root.Object, "status", "operationState", "message")
	if !strings.Contains(message, "blocked by sync window") {
		return nil
	}
	if phase == "Terminating" {
		return &Issue{
			Severity: SeverityWarning,
			Scope:    ScopeOperation,
			Reason:   "SyncWindowBlocked",
			Message:  "This operation was terminated while a sync window held it. Argo CD completes the termination when a window allows syncing. It applies nothing more; changes already applied stay.",
			Action:   "Wait for the window to open, or change the AppProject's sync windows.",
		}
	}
	return &Issue{
		Severity: SeverityWarning,
		Scope:    ScopeOperation,
		Reason:   "SyncWindowBlocked",
		Message:  "A sync window is holding this sync. Argo CD continues it when the AppProject's sync windows allow syncing.",
		Action:   "Wait for the window, or terminate the operation to drop it. Termination also waits for a window; changes already applied stay.",
	}
}

// dedupeIssues removes Issues that share the same (scope, reason, message,
// firstRef) tuple as an earlier entry. Keeps the first occurrence and
// discards later duplicates, preserving detector-chain ordering.
//
// Including the first Ref's Kind+Name in the key keeps per-resource
// issues distinct (two pods both ImagePullBackOff with the same message
// but different refs stay as two issues). Issues without refs
// (operation/condition/lifecycle scopes) collapse correctly because
// their ref-suffix is "" identically.
func dedupeIssues(in []Issue) []Issue {
	if len(in) < 2 {
		return in
	}
	seen := make(map[string]struct{}, len(in))
	out := make([]Issue, 0, len(in))
	for _, i := range in {
		// Refs differentiate per-resource issues; include the first ref's
		// namespace+kind+name in the dedup key so a class of resource-level
		// issues isn't silently collapsed into one. Namespace is load-bearing:
		// two genuinely distinct Degraded resources that share a kind+name across
		// namespaces (an ApplicationSet fanning out an identically-named workload)
		// must both survive — dropping the second would hide a real problem.
		// Empty refs (operation/condition/lifecycle scopes) collapse correctly
		// because their ref-suffix is "" identically.
		var refKey string
		if len(i.Refs) > 0 {
			refKey = i.Refs[0].Namespace + "/" + i.Refs[0].Kind + "/" + i.Refs[0].Name
		}
		k := string(i.Scope) + "|" + i.Reason + "|" + i.Message + "|" + refKey
		if _, ok := seen[k]; ok {
			continue
		}
		seen[k] = struct{}{}
		out = append(out, i)
	}
	return out
}
func buildChanges(root *unstructured.Unstructured, resourceTree *gitopstree.ResourceTree, tool string, live Resolver) []Change {
	if tool == "argocd" {
		// Drift diffs and recent events come from this cluster; for an app
		// deploying elsewhere they would describe unrelated same-named local
		// objects, so a remote app's rows carry CR data only.
		if resourceTree != nil && resourceTree.RemoteDestination {
			live = nil
		}
		return argoResourceChanges(root, resourceTree, live)
	}
	if resourceTree == nil {
		return nil
	}
	related := map[string]gitopstree.EdgeType{}
	for _, edge := range resourceTree.Edges {
		if edge.Type == gitopstree.EdgeSource || edge.Type == gitopstree.EdgeDependsOn {
			related[edge.Target] = edge.Type
		}
	}
	var out []Change
	for _, n := range resourceTree.Nodes {
		if n.Role == gitopstree.RoleRoot || n.Role == gitopstree.RoleGroup {
			continue
		}
		category := categorizeFluxChange(n.Sync, n.Health)
		if n.Dependency && (n.Sync == "" || n.Sync == "Unknown") && (category == CategorySynced || n.Health == "" || n.Health == "Unknown") {
			category = CategoryUnknown
		}
		partial := true
		note := "Flux inventory confirms this resource is managed; desired manifest content is not available in Radar yet."
		if n.Dependency {
			switch related[n.ID] {
			case gitopstree.EdgeSource:
				note = "Flux declares this resource as a source dependency; this reference does not establish ownership or a desired manifest."
			case gitopstree.EdgeDependsOn:
				note = "Flux declares this resource as an ordering dependency; this reference does not establish ownership or a desired manifest."
			}
		}
		out = append(out, Change{
			Ref:         refFromTree(n.Ref),
			Category:    category,
			Sync:        n.Sync,
			Health:      firstNonEmpty(n.Health, n.TopologyStatus),
			HasLive:     n.Ref.UID != "",
			HasDesired:  false,
			Partial:     partial,
			PartialNote: note,
			Dependency:  n.Dependency,
		})
	}
	sortChanges(out)
	return out
}

// argoIgnoreDifferences holds a parsed view of the Application's
// spec.ignoreDifferences[]. Each entry scopes a set of JSON pointers to a
// (group, kind, optional name, optional namespace) — the same shape Argo's
// own diff pipeline reads. pointersFor returns the pointer list applicable
// to a given resource ref, with broader rules (no name/namespace) and
// narrower rules (named resource) both contributing.
type argoIgnoreDifferences struct {
	rules []argoIgnoreRule
}

type argoIgnoreRule struct {
	group, kind, name, namespace string
	pointers                     []string
}

func parseArgoIgnoreDifferences(root *unstructured.Unstructured) argoIgnoreDifferences {
	raw, _, _ := unstructured.NestedSlice(root.Object, "spec", "ignoreDifferences")
	out := argoIgnoreDifferences{}
	for _, item := range raw {
		m, ok := item.(map[string]any)
		if !ok {
			continue
		}
		ptrs, _, _ := unstructured.NestedStringSlice(m, "jsonPointers")
		// jqPathExpressions are a separate Argo feature (more powerful but
		// requires a JQ engine to evaluate). Skip them here — JSONPointer
		// covers the majority of "ignore this field" rules. Future work.
		if len(ptrs) == 0 {
			// Affected rules disappear silently otherwise; surface a one-
			// shot warning so operators can correlate "Radar shows drift
			// Argo's UI suppresses" with the gap.
			jq, _, _ := unstructured.NestedStringSlice(m, "jqPathExpressions")
			if len(jq) > 0 {
				logJQIgnoreOnce(gitops.StringValue(m["group"]), gitops.StringValue(m["kind"]))
			}
			continue
		}
		out.rules = append(out.rules, argoIgnoreRule{
			group:     gitops.StringValue(m["group"]),
			kind:      gitops.StringValue(m["kind"]),
			name:      gitops.StringValue(m["name"]),
			namespace: gitops.StringValue(m["namespace"]),
			pointers:  ptrs,
		})
	}
	return out
}

func (a argoIgnoreDifferences) pointersFor(ref Ref) []string {
	if len(a.rules) == 0 {
		return nil
	}
	var out []string
	for _, r := range a.rules {
		if r.matches(ref) {
			out = append(out, r.pointers...)
		}
	}
	return out
}

// matches reports whether the rule applies to the given resource ref.
// All four scope fields (group/kind/name/namespace) treat empty-string as
// a wildcard — Argo's own scoping semantics. A rule that omits `group` and
// `kind` matches every resource; a rule that names `name` narrows to that
// single resource. Matching upstream behavior matters because operators
// copy Argo Application manifests verbatim and expect the same effect.
func (r argoIgnoreRule) matches(ref Ref) bool {
	if r.group != "" && r.group != ref.Group {
		return false
	}
	if r.kind != "" && r.kind != ref.Kind {
		return false
	}
	if r.name != "" && r.name != ref.Name {
		return false
	}
	if r.namespace != "" && r.namespace != ref.Namespace {
		return false
	}
	return true
}

// summarizeArgoIgnoreDifferences builds the comparison-coverage disclosure for
// an Argo Application by walking spec.ignoreDifferences directly. This is a
// deliberately separate pass from parseArgoIgnoreDifferences (which feeds the
// drift filter and retains only jsonPointer rules): the summary needs the raw
// entry count, the jq-rule count, and the target kinds — data the parser
// discards. Returns nil when the Application declares no exclusions so the UI
// renders nothing.
func summarizeArgoIgnoreDifferences(root *unstructured.Unstructured) *IgnoredDifferencesSummary {
	raw, _, _ := unstructured.NestedSlice(root.Object, "spec", "ignoreDifferences")
	if len(raw) == 0 {
		return nil
	}
	out := &IgnoredDifferencesSummary{}
	seen := map[string]struct{}{}
	for _, item := range raw {
		m, ok := item.(map[string]any)
		if !ok {
			continue
		}
		out.RuleCount++
		jq, _, _ := unstructured.NestedStringSlice(m, "jqPathExpressions")
		mfm, _, _ := unstructured.NestedStringSlice(m, "managedFieldsManagers")
		if len(jq) > 0 || len(mfm) > 0 {
			out.UnsupportedRuleCount++
		}
		label := ignoreDifferenceKindLabel(gitops.StringValue(m["group"]), gitops.StringValue(m["kind"]))
		if _, dup := seen[label]; !dup {
			seen[label] = struct{}{}
			out.Kinds = append(out.Kinds, label)
		}
	}
	if out.RuleCount == 0 {
		return nil
	}
	sort.Strings(out.Kinds)
	return out
}

// ignoreDifferenceKindLabel formats an ignoreDifferences rule's (group, kind)
// into a display token: "group/kind", or just "kind" for core resources
// (empty group). A rule that omits kind is a group-wide wildcard in Argo's
// matching semantics, rendered as "group/*" (or "*" when group is empty too).
// Literal "*" values the operator wrote are passed through unchanged.
func ignoreDifferenceKindLabel(group, kind string) string {
	if kind == "" {
		kind = "*"
	}
	if group == "" {
		return kind
	}
	return group + "/" + kind
}

// ManagedResourceRow is the minimal projection of one Argo Application
// status.resources entry — just enough for a host to decide which resources
// deserve a live-state fetch (drift is only meaningful where the controller
// reports the row as not cleanly Synced) without building full Changes.
type ManagedResourceRow struct {
	Ref          Ref
	Sync         string
	HasSyncError bool
}

// ManagedResourceRows parses status.resources into rows. Same source and
// filtering as the Changes builder (rows without kind+name are dropped);
// hosts use it to schedule the Resolver's live-state prefetch before Build.
func ManagedResourceRows(root *unstructured.Unstructured) []ManagedResourceRow {
	if root == nil {
		return nil
	}
	raw, _, _ := unstructured.NestedSlice(root.Object, "status", "resources")
	out := make([]ManagedResourceRow, 0, len(raw))
	for _, item := range raw {
		m, ok := item.(map[string]any)
		if !ok {
			continue
		}
		ref := Ref{
			Group:     gitops.StringValue(m["group"]),
			Kind:      gitops.StringValue(m["kind"]),
			Namespace: gitops.StringValue(m["namespace"]),
			Name:      gitops.StringValue(m["name"]),
		}
		if ref.Kind == "" || ref.Name == "" {
			continue
		}
		row := ManagedResourceRow{Ref: ref, Sync: gitops.StringValue(m["status"])}
		if sr, ok := m["syncResult"].(map[string]any); ok {
			status := gitops.StringValue(sr["status"])
			if status != "Synced" && status != "Pruned" && gitops.StringValue(sr["message"]) != "" {
				row.HasSyncError = true
			}
		}
		out = append(out, row)
	}
	return out
}

// OperationPhase returns status.operationState.phase ("" when absent).
// Hosts use it to skip live-state enrichment while a sync is in flight —
// the 2s progress poll should be a cache read, not an apiserver fan-out,
// and mid-apply drift is churn rather than signal.
func OperationPhase(root *unstructured.Unstructured) string {
	if root == nil {
		return ""
	}
	phase, _, _ := unstructured.NestedString(root.Object, "status", "operationState", "phase")
	return phase
}

// argoResourceChanges lists the Application's managed resources with their
// resolved health. Health comes from the CR entry when Argo persisted it;
// otherwise from the resource tree's node, which carries whatever Radar could
// establish (topology status, or a host-overlaid issues-engine finding) with
// its provenance. Reading the tree here rather than the CR twice keeps the
// Changes table, the per-resource Issues and the tree summary on one
// authority.
func argoResourceChanges(root *unstructured.Unstructured, resourceTree *gitopstree.ResourceTree, resolver Resolver) []Change {
	raw, _, _ := unstructured.NestedSlice(root.Object, "status", "resources")
	// Pre-parse the Application's spec.ignoreDifferences so each resource's
	// drift computation can filter out operator-declared exemptions before
	// they reach the UI.
	ignoreRules := parseArgoIgnoreDifferences(root)
	treeNodes := map[string]gitopstree.Node{}
	if resourceTree != nil {
		for _, n := range resourceTree.Nodes {
			treeNodes[healthRefKey(n.Ref.Group, n.Ref.Kind, n.Ref.Namespace, n.Ref.Name)] = n
		}
	}
	out := make([]Change, 0, len(raw))
	for _, item := range raw {
		m, ok := item.(map[string]any)
		if !ok {
			continue
		}
		ref := Ref{
			Group:     gitops.StringValue(m["group"]),
			Kind:      gitops.StringValue(m["kind"]),
			Namespace: gitops.StringValue(m["namespace"]),
			Name:      gitops.StringValue(m["name"]),
		}
		if ref.Kind == "" || ref.Name == "" {
			continue
		}
		health := ""
		if hm, ok := m["health"].(map[string]any); ok {
			health = gitops.StringValue(hm["status"])
		}
		healthSource, healthReason, healthSeverity := "", "", ""
		message := nestedMessage(m["health"])
		// When the host filled the tree from the controller's API, that is
		// the current verdict; a value the CR still carries inline is older.
		fromAPI := resourceTree != nil && resourceTree.HealthFromAPI
		if fromAPI {
			health, message = "", ""
		}
		if health != "" {
			healthSource = string(gitopstree.HealthSourceController)
		} else if n, ok := treeNodes[healthRefKey(ref.Group, ref.Kind, ref.Namespace, ref.Name)]; ok {
			health = n.Health
			healthSource = string(n.HealthSource)
			healthReason = n.HealthReason
			healthSeverity = n.HealthSeverity
			message = fallback(n.HealthMessage, message)
		}
		sync := gitops.StringValue(m["status"])
		missing := treeNodes[healthRefKey(ref.Group, ref.Kind, ref.Namespace, ref.Name)].Missing
		category := categorizeArgoChange(sync, health)
		// Argo records per-resource sync failures under a syncResult sibling
		// (set during/after a failed sync attempt). Surface the message as
		// an error unless status explicitly marks success ("Synced"/"Pruned").
		// Empty status counts as "unknown — show the message" because Argo
		// can write a pre-apply failure message before stamping a status.
		syncError := ""
		rawSyncError := ""
		hookPhase := ""
		if sr, ok := m["syncResult"].(map[string]any); ok {
			status := gitops.StringValue(sr["status"])
			if status != "Synced" && status != "Pruned" {
				syncError, rawSyncError = diagnose.CleanArgoControllerMessageWithRaw(gitops.StringValue(sr["message"]))
			}
			hookPhase = gitops.StringValue(sr["hookPhase"])
		}
		change := Change{
			Ref:            ref,
			Category:       category,
			Sync:           sync,
			Health:         health,
			HealthSource:   healthSource,
			HealthReason:   healthReason,
			HealthSeverity: healthSeverity,
			Message:        message,
			SyncError:      syncError,
			RawSyncError:   rawSyncError,
			HookPhase:      hookPhase,
			HasDesired:     false,
			HasLive:        !missing,
			Missing:        missing,
			Partial:        true,
			PartialNote:    "Argo reports resource status here; desired manifest content is not available in Radar yet.",
		}
		// Enrich from live cluster state when a resolver is wired. The
		// drift diff turns the bare "OutOfSync" badge into a concrete
		// list of differing fields; recent events surface the underlying
		// "why is this stuck" cause for things like ImagePullBackOff or
		// FailedScheduling that the GitOps CR never sees.
		if resolver != nil {
			if live := resolver.GetLive(ref.Group, ref.Kind, ref.Namespace, ref.Name); live != nil {
				change.Missing, change.HasLive = false, true
				if drift := computeDriftFromLastApplied(live, ignoreRules.pointersFor(ref)); drift != nil {
					change.Drift = drift
				}
			}
			if events := resolver.RecentEvents(ref.Group, ref.Kind, ref.Namespace, ref.Name); len(events) > 0 {
				change.RecentEvents = events
			}
		}
		out = append(out, change)
	}
	sortChanges(out)
	return out
}

func healthRefKey(group, kind, namespace, name string) string {
	return group + "|" + kind + "|" + namespace + "|" + name
}

func buildPlan(root *unstructured.Unstructured, resourceTree *gitopstree.ResourceTree, tool string) []PlanItem {
	if resourceTree == nil {
		return nil
	}
	items := make([]PlanItem, 0, len(resourceTree.Nodes))
	for _, n := range resourceTree.Nodes {
		if n.Role == gitopstree.RoleGroup {
			continue
		}
		// The root node is the GitOps CR itself (Application / Kustomization /
		// HelmRelease). Including it in the plan reads as "the controller will
		// sync itself," which is an Argo internal that confuses operators. The
		// plan is about what gets applied to the cluster as a result of sync;
		// the root is the trigger, not a planned change.
		if n.Role == gitopstree.RoleRoot {
			continue
		}
		item := PlanItem{
			Ref:          refFromTree(n.Ref),
			Order:        len(items) + 1,
			Hook:         stringData(n.Data, "hook"),
			Relationship: stripUnknown(stringData(n.Data, "relationship")),
			// Strip "Unknown" tokens before joining — Sync/Health/TopologyStatus
			// each default to "Unknown" when the controller hasn't reported,
			// so a raw join produces noise like "OutOfSync · Unknown · unknown"
			// that reads as broken in the UI chip.
			Status: joinNonEmpty(stripUnknown(n.Sync), stripUnknown(n.Health), stripUnknown(n.TopologyStatus)),
		}
		if wave, ok := parseWave(stringData(n.Data, "syncWave")); ok {
			item.Wave = wave
			item.WaveSet = true
		}
		item.Phase = firstNonEmpty(stringData(n.Data, "syncPhase"), phaseFromHook(item.Hook))
		if tool == "fluxcd" && item.Relationship == "" {
			if n.Role == gitopstree.RoleRoot {
				item.Relationship = "root"
			} else {
				item.Relationship = "managed"
			}
		}
		items = append(items, item)
	}
	sort.SliceStable(items, func(i, j int) bool {
		if tool == "argocd" {
			if phaseRank(items[i].Phase) != phaseRank(items[j].Phase) {
				return phaseRank(items[i].Phase) < phaseRank(items[j].Phase)
			}
			if items[i].Wave != items[j].Wave {
				return items[i].Wave < items[j].Wave
			}
		}
		if kindRank(items[i].Ref.Kind) != kindRank(items[j].Ref.Kind) {
			return kindRank(items[i].Ref.Kind) < kindRank(items[j].Ref.Kind)
		}
		return items[i].Ref.Name < items[j].Ref.Name
	})
	for i := range items {
		items[i].Order = i + 1
	}
	return items
}

func buildHistory(root *unstructured.Unstructured, tool string) []HistoryItem {
	if tool == "argocd" {
		currentSources, _, _ := unstructured.NestedSlice(root.Object, "spec", "sources")
		raw, _, _ := unstructured.NestedSlice(root.Object, "status", "history")
		out := make([]HistoryItem, 0, len(raw)+1)
		for _, item := range raw {
			m, ok := item.(map[string]any)
			if !ok {
				continue
			}
			id := ""
			switch v := m["id"].(type) {
			case int64:
				id = strconv.FormatInt(v, 10)
			case float64:
				// JSON numbers decode as float64; client-go's structured
				// deep-copy preserves int64 — both branches are reachable.
				id = strconv.Itoa(int(v))
			default:
				if m["id"] != nil {
					log.Printf("[gitops/insights] history entry %s/%s has unexpected id type %T (%v); rollback for this entry will be unavailable", root.GetNamespace(), root.GetName(), m["id"], m["id"])
				}
			}
			// initiatedBy carries who triggered the sync. Username is set for
			// human/api triggers; automated is a *bool*, not a string — Argo
			// flips it true when the controller's auto-sync fires. We coerce
			// to "automated" so the UI doesn't show empty initiator on
			// controller-triggered history rows (the common case).
			initiatedBy := ""
			if ib, ok := m["initiatedBy"].(map[string]any); ok {
				initiatedBy = gitops.StringValue(ib["username"])
				if initiatedBy == "" {
					if auto, ok := ib["automated"].(bool); ok && auto {
						initiatedBy = "automated"
					}
				}
			}
			rollbackable := gitops.ArgoHistoryEntryRollbackable(m)
			sources, _, _ := unstructured.NestedSlice(m, "sources")
			revisions, _, _ := unstructured.NestedStringSlice(m, "revisions")
			out = append(out, HistoryItem{ID: id, Revision: argoRevisionLabel(m), DeployedAt: gitops.StringValue(m["deployedAt"]), Source: argoSourceLabel(m), InitiatedBy: initiatedBy, Rollbackable: &rollbackable, RollbackMultiSourceWarning: len(currentSources) > 0 || len(sources) > 0 || len(revisions) > 0})
		}
		if op, ok, _ := unstructured.NestedMap(root.Object, "status", "operationState"); ok {
			initiatedBy := ""
			if opMap, ok := op["operation"].(map[string]any); ok {
				if ib, ok := opMap["initiatedBy"].(map[string]any); ok {
					initiatedBy = gitops.StringValue(ib["username"])
					if initiatedBy == "" {
						if auto, ok := ib["automated"].(bool); ok && auto {
							initiatedBy = "automated"
						}
					}
				}
			}
			// finishedAt is empty while a sync is in flight. Fall back to
			// startedAt so the running entry still has a timestamp; without
			// this, the descending sort below pushed the in-flight row to
			// the *bottom* of history, hiding the most operationally
			// relevant entry from the user.
			deployedAt := gitops.StringValue(op["finishedAt"])
			if deployedAt == "" {
				deployedAt = gitops.StringValue(op["startedAt"])
			}
			msg, rawMsg := diagnose.CleanArgoControllerMessageWithRaw(gitops.StringValue(op["message"]))
			syncResult, _ := op["syncResult"].(map[string]any)
			out = append(out, HistoryItem{
				Phase:       gitops.StringValue(op["phase"]),
				Message:     msg,
				RawMessage:  rawMsg,
				DeployedAt:  deployedAt,
				Revision:    argoRevisionLabel(syncResult),
				InitiatedBy: initiatedBy,
			})
		}
		sort.SliceStable(out, func(i, j int) bool { return out[i].DeployedAt > out[j].DeployedAt })
		return out
	}
	var out []HistoryItem
	// Dedupe by (message, reason). Flux HelmReleases routinely carry the
	// same message on multiple conditions (Released=True and Ready=True both
	// report "Helm install succeeded for release X with chart Y@Z") with
	// timestamps a second apart, so timestamp can't be part of the key.
	// Same message + same reason = one logical event surfaced redundantly.
	seen := make(map[string]struct{})
	revision := firstNonEmpty(nestedString(root.Object, "status", "lastAppliedRevision"), nestedString(root.Object, "status", "lastAttemptedRevision"))
	for _, c := range conditions(root) {
		key := c.message + "|" + c.reason
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		out = append(out, HistoryItem{
			ID:         c.typ,
			Phase:      fluxPhaseLabel(c.status, c.reason),
			Message:    c.message,
			DeployedAt: c.lastTransitionTime,
			Revision:   revision,
		})
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].DeployedAt > out[j].DeployedAt })
	return out
}

func buildCapabilities(root *unstructured.Unstructured, tool string) Capabilities {
	if tool == "argocd" {
		if root.GetKind() != "Application" {
			return Capabilities{UnsupportedReason: "GitOps actions are only available for Argo CD Applications."}
		}
		// A multi-source entry records `revisions`, not `revision`, so the
		// entry's source is what makes it rollbackable.
		hasHistory := false
		raw, _, _ := unstructured.NestedSlice(root.Object, "status", "history")
		for _, item := range raw {
			if m, ok := item.(map[string]any); ok && gitops.ArgoHistoryEntryRollbackable(m) {
				hasHistory = true
				break
			}
		}
		return Capabilities{Sync: true, Refresh: true, Terminate: true, Suspend: true, Resume: true, SelectiveSync: true, Rollback: hasHistory, Warnings: []string{"Selective sync skips hooks and is not equivalent to a full application sync."}}
	}
	syncWithSource := root.GetKind() == "Kustomization" || root.GetKind() == "HelmRelease"
	return Capabilities{Sync: true, Suspend: true, Resume: true, SyncWithSource: syncWithSource, UnsupportedReason: "Flux reconciles through source/workload controllers; per-resource selective sync and generic rollback are not exposed by Radar."}
}

type condition struct {
	typ                string
	status             string
	reason             string
	message            string
	lastTransitionTime string
}

func conditions(root *unstructured.Unstructured) []condition {
	raw, _, _ := unstructured.NestedSlice(root.Object, "status", "conditions")
	out := make([]condition, 0, len(raw))
	for _, item := range raw {
		m, ok := item.(map[string]any)
		if !ok {
			continue
		}
		out = append(out, condition{
			typ:                gitops.StringValue(m["type"]),
			status:             gitops.StringValue(m["status"]),
			reason:             gitops.StringValue(m["reason"]),
			message:            gitops.StringValue(m["message"]),
			lastTransitionTime: gitops.StringValue(m["lastTransitionTime"]),
		})
	}
	return out
}

type fluxState struct {
	sync   string
	health string
}

func fluxStatus(root *unstructured.Unstructured) fluxState {
	st := gitops.FluxStatus(root)
	return fluxState{sync: st.Sync, health: st.Health}
}

func nestedRef(root *unstructured.Unstructured, fields ...string) (Ref, bool) {
	m, ok, _ := unstructured.NestedMap(root.Object, fields...)
	if !ok {
		return Ref{}, false
	}
	name := gitops.StringValue(m["name"])
	kind := gitops.StringValue(m["kind"])
	if name == "" || kind == "" {
		return Ref{}, false
	}
	return Ref{Group: resourceid.GroupFromAPIVersion(gitops.StringValue(m["apiVersion"])), Kind: kind, Namespace: firstNonEmpty(gitops.StringValue(m["namespace"]), root.GetNamespace()), Name: name}, true
}

func refFromTree(ref gitopstree.ResourceRef) Ref {
	return Ref{Group: ref.Group, Kind: ref.Kind, Namespace: ref.Namespace, Name: ref.Name}
}

func sortChanges(out []Change) {
	sort.SliceStable(out, func(i, j int) bool {
		if changeRank(out[i].Category) != changeRank(out[j].Category) {
			return changeRank(out[i].Category) < changeRank(out[j].Category)
		}
		if out[i].Ref.Kind != out[j].Ref.Kind {
			return out[i].Ref.Kind < out[j].Ref.Kind
		}
		return out[i].Ref.Name < out[j].Ref.Name
	})
}

// unknownPairLogged dedupes the "unknown vocabulary" warnings emitted by
// categorizeArgoChange / categorizeFluxChange. The GitOps detail page polls
// every 2s while an op runs, with one call per managed resource — without
// dedup, a single non-canonical health value (e.g. a controller emitting
// `OK` instead of `Healthy`) would flood the log at hundreds of lines/min.
// The set grows monotonically, but the vocabulary is closed and small, so
// memory use is bounded by the cluster's actual non-canonical value count.
var unknownPairLogged sync.Map

func logUnknownPairOnce(tool, sync, health string) {
	key := tool + "|" + sync + "|" + health
	if _, loaded := unknownPairLogged.LoadOrStore(key, struct{}{}); loaded {
		return
	}
	log.Printf("[gitops/insights] unknown %s sync/health combination sync=%q health=%q — falling back to Unknown", tool, sync, health)
}

// categorizeArgoChange maps Argo's per-resource sync + health into a Category
// constant. Inputs come from status.resources[].status (sync) and
// status.resources[].health.status — both vocabularies are documented and
// stable, so unknown values are a real bug and are logged once per
// (sync, health) pair via logUnknownPairOnce.
func categorizeArgoChange(sync, health string) Category {
	// Health takes precedence for the failure tiers — a resource in Sync
	// but degraded is more important to surface than its sync state.
	switch health {
	case "Degraded":
		return CategoryDegraded
	case "Missing":
		return CategoryMissing
	case "Progressing":
		return CategoryProgressing
	case "Suspended":
		return CategorySuspended
	}
	switch sync {
	case "Synced":
		return CategorySynced
	case "OutOfSync":
		return CategoryOutOfSync
	case "Pruned":
		return CategoryPruned
	case "Unknown", "":
		return CategoryUnknown
	}
	logUnknownPairOnce("Argo", sync, health)
	return CategoryUnknown
}

// categorizeFluxChange does the same for Flux's tree-derived sync/health.
// Inputs are the gitopstree.Node fields rather than raw Flux conditions.
func categorizeFluxChange(sync, health string) Category {
	switch health {
	case "Degraded":
		return CategoryDegraded
	case "Missing":
		return CategoryMissing
	case "Progressing":
		return CategoryProgressing
	case "Suspended":
		return CategorySuspended
	}
	if sync == "OutOfSync" {
		return CategoryOutOfSync
	}
	// Flux managed resources without a degraded health are reported as
	// Synced — they pass the inventory check; per-field drift would need
	// the desired-manifest path that doesn't exist yet.
	return CategorySynced
}

func changeRank(category Category) int {
	switch category {
	case CategoryDegraded, CategoryMissing:
		return 0
	case CategoryOutOfSync:
		return 1
	case CategoryProgressing, CategoryReconciling:
		return 2
	case CategoryUnknown:
		return 3
	case CategorySynced, CategoryPruned, CategoryHook, CategorySuspended:
		return 4
	default:
		// Unknown Category values surface here only via the categorize*
		// helpers' fallback path, which already logs. Sort them at the end.
		return 5
	}
}

// severityRank orders Issues for the buildIssues output sort.
// Critical → alert → warning → info → unknown. Matches the project-wide
// severity vocabulary in CLAUDE.md; the alert tier is the intermediate
// between degraded/amber and unhealthy/red.
func severityRank(severity Severity) int {
	switch severity {
	case SeverityCritical:
		return 0
	case SeverityAlert:
		return 1
	case SeverityWarning:
		return 2
	case SeverityInfo:
		return 3
	default:
		return 4
	}
}

func phaseRank(phase string) int {
	switch phase {
	case "PreSync":
		return 0
	case "", "Sync":
		return 1
	case "PostSync":
		return 2
	case "SyncFail":
		return 3
	case "PostDelete":
		return 4
	default:
		return 5
	}
}

func kindRank(kind string) int {
	switch kind {
	case "Namespace":
		return 0
	case "CustomResourceDefinition":
		return 1
	case "ServiceAccount", "Role", "RoleBinding", "ClusterRole", "ClusterRoleBinding":
		return 2
	case "Secret", "ConfigMap":
		return 3
	case "Service", "Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob":
		return 4
	default:
		return 5
	}
}

func phaseFromHook(hook string) string {
	if hook == "" || hook == "Skip" {
		return ""
	}
	return hook
}

func parseWave(value string) (int, bool) {
	if value == "" {
		return 0, false
	}
	i, err := strconv.Atoi(value)
	return i, err == nil
}

func stringData(data map[string]any, key string) string {
	if data == nil {
		return ""
	}
	return gitops.StringValue(data[key])
}

func nestedString(v any, fields ...string) string {
	m, ok := v.(map[string]any)
	if !ok {
		return ""
	}
	for i, field := range fields {
		if i == len(fields)-1 {
			return gitops.StringValue(m[field])
		}
		m, ok = m[field].(map[string]any)
		if !ok {
			return ""
		}
	}
	return ""
}

func nestedMessage(v any) string {
	if m, ok := v.(map[string]any); ok {
		return gitops.StringValue(m["message"])
	}
	return ""
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if value != "" {
			return value
		}
	}
	return ""
}

func fallback(value, fallback string) string {
	if value != "" {
		return value
	}
	return fallback
}

// stripUnknown returns "" for strings that carry no signal (empty or
// case-insensitive "unknown"), so callers can use joinNonEmpty without
// dragging "Unknown" placeholders into compound display strings.
func stripUnknown(value string) string {
	if strings.EqualFold(value, "unknown") {
		return ""
	}
	return value
}

// argoRevisionLabel is a history entry's (or sync result's) deployed revision.
// A multi-source deploy records one revision per source in `revisions` and
// leaves `revision` empty.
func argoRevisionLabel(m map[string]any) string {
	if revision := gitops.StringValue(m["revision"]); revision != "" {
		return revision
	}
	revisions, _ := m["revisions"].([]any)
	parts := make([]string, 0, len(revisions))
	for _, revision := range revisions {
		if s := gitops.StringValue(revision); s != "" {
			parts = append(parts, s)
		}
	}
	return strings.Join(parts, ", ")
}

// argoSourceLabel describes the source a history entry deployed. A
// multi-source entry carries `sources` (its `source` is an empty placeholder),
// labelled by the first source and a count of the rest.
func argoSourceLabel(m map[string]any) string {
	describe := func(source any) string {
		sm, _ := source.(map[string]any)
		return joinNonEmpty(gitops.StringValue(sm["repoURL"]), gitops.StringValue(sm["path"]), gitops.StringValue(sm["chart"]))
	}
	if label := describe(m["source"]); label != "" {
		return label
	}
	sources, _ := m["sources"].([]any)
	if len(sources) == 0 {
		return ""
	}
	label := describe(sources[0])
	if len(sources) > 1 {
		label += fmt.Sprintf(" (+%d more)", len(sources)-1)
	}
	return label
}

func joinNonEmpty(values ...string) string {
	var parts []string
	for _, value := range values {
		if value != "" {
			parts = append(parts, value)
		}
	}
	return strings.Join(parts, " · ")
}

// fluxPhaseLabel collapses Flux's per-condition (status, reason) pair into a
// single outcome word — the value goes into HistoryItem.Phase, which the
// frontend renders as a colored chip on each history row. Without this, the
// raw join surfaces internal encodings like "True · installsucceeded" to the
// user; the cleaned label reads as the actual outcome ("Succeeded", "Failed",
// "Reconciling") and matches the vocabulary the FE's gitopsToSeverity already
// understands.
func fluxPhaseLabel(status, reason string) string {
	r := strings.ToLower(reason)
	switch {
	case strings.Contains(r, "succeed"):
		return "Succeeded"
	case strings.Contains(r, "fail"):
		return "Failed"
	case strings.Contains(r, "error"):
		return "Failed"
	case strings.Contains(r, "progress"), strings.Contains(r, "reconcil"):
		return "Reconciling"
	case strings.Contains(r, "suspend"):
		return "Suspended"
	}
	// Unknown reason — fall back to the condition status alone. True/False are
	// less informative than a named outcome but better than joining both into
	// a hybrid that confuses readers.
	if reason != "" {
		return reason
	}
	return status
}

// refKey is the key used to dedup issue refs across the operation+resource
// pass. Group is intentionally omitted — the operation message rarely
// includes it, and kind+name+namespace is enough disambiguation in practice.
func refKey(r Ref) string {
	return r.Kind + "/" + r.Namespace + "/" + r.Name
}

// suppressionKey matches a resource by kind+name only — Argo's operation
// failure message names the affected resource without a namespace, so a
// suppression set built from operation messages must compare on the same
// projection. Two same-named resources of the same kind in different
// namespaces would dedup with each other under this key; in practice
// operations only affect one of them at a time and the cost of over-dedup
// (showing one too few rows briefly) is less than the cost of under-dedup
// (every namespaced resource fails the namespace-aware refKey match and the
// duplicate rows persist forever — the exact regression this exists to
// prevent).
func suppressionKey(r Ref) string {
	return r.Kind + "/" + r.Name
}

// remediationFromParsed adapts the vocabulary-neutral remediation primitives
// from pkg/gitops/diagnose onto the insights Remediation wire type. Returns nil
// when no structured remediation was parsed (the common case) or when the
// target is empty (NewCreateNamespaceRemediation enforces that invariant).
func remediationFromParsed(p diagnose.ParsedFailure) *Remediation {
	switch p.RemediationKind {
	case diagnose.RemediationCreateNamespace:
		return NewCreateNamespaceRemediation(p.RemediationTarget, p.RemediationHint)
	default:
		return nil
	}
}

// jqIgnoreLogged deduplicates the "jq-only ignoreDifferences" warning so it
// fires once per (group, kind) over the process lifetime — Argo Application
// reconciles emit insights every 2s, and operators don't need the same
// warning a thousand times.
var jqIgnoreLogged sync.Map

func logJQIgnoreOnce(group, kind string) {
	key := group + "/" + kind
	if _, loaded := jqIgnoreLogged.LoadOrStore(key, struct{}{}); loaded {
		return
	}
	log.Printf("[gitops/drift] ignoreDifferences rule for %s/%s uses jqPathExpressions which Radar doesn't evaluate; some drift entries Argo's UI suppresses may appear here", group, kind)
}

// detectPendingDeletion returns an Issue when the GitOps root resource has
// metadata.deletionTimestamp set. Tool-agnostic — applies to both Argo
// Applications and Flux Kustomizations/HelmReleases.
//
// Severity ramps with how long deletion has been pending:
//
//	<5min  → info     ("Deletion in progress, finalizers running")
//	5-30m  → warning  ("Deletion pending — finalizers blocking")
//	>30m   → alert    ("Deletion stuck — controller likely unhealthy")
//
// The 5min threshold gives healthy controllers time to run their finalizers.
// The 30min threshold is the boundary past which any reasonable cleanup
// would have completed; beyond it the finalizer's owning controller is
// almost certainly the problem (CrashLoopBackOff, network partition, etc).
//
// Why this matters: a resource with deletionTimestamp is queryable but
// any mutating action on it is futile (Sync/Reconcile/Rollback all fail
// or no-op because the resource is being torn down). Without this issue,
// the user sees Sync/Health badges that look "live" and clicks buttons
// that produce confusing K8s errors. Returns nil on a healthy resource —
// caller appends only on hit.
func detectPendingDeletion(root *unstructured.Unstructured, resolver Resolver) *Issue {
	dt := root.GetDeletionTimestamp()
	if dt == nil || dt.IsZero() {
		return nil
	}
	finalizers := root.GetFinalizers()
	age := time.Since(dt.Time)
	// Clock-skew guard: a deletionTimestamp meaningfully in the future
	// usually means Radar's local clock is behind the cluster API server
	// (or vice versa). Without this, the severity ramp would treat the
	// resource as "started 0s ago, info severity" and skip the controller-
	// health enrichment that gates on warning+. A 6h-stuck zombie with
	// even moderate clock skew would silently demote. Surface the skew
	// explicitly so the operator can investigate NTP rather than chase a
	// phantom info-tier issue.
	if age < -1*time.Minute {
		return &Issue{
			Severity: SeverityInfo,
			Scope:    ScopeLifecycle,
			Reason:   "Terminating",
			Message:  fmt.Sprintf("This resource is being deleted, but its deletionTimestamp (%s) is in the future relative to Radar — likely clock skew between Radar and the cluster API server.", dt.UTC().Format(time.RFC3339)),
			Action:   "Verify NTP / time sync. Once clocks agree, the lifecycle severity will reflect the true deletion age.",
		}
	}
	if age < 0 {
		age = 0
	}
	rel := timeutil.FormatAgeShort(age)

	severity := SeverityInfo
	reason := "Terminating"
	msg := fmt.Sprintf("This resource is being deleted (started %s ago).", rel)
	action := "Wait for finalizers to complete cleanup."
	// Inclusive thresholds (>=) — at exactly the boundary, escalate.
	// Two reasons: matches user intuition ("by 30 minutes this is stuck"),
	// and avoids flaky tests where time.Since drifts micro-seconds past
	// the cutoff between Now() and the comparison.
	//
	// keep in sync: pkg/audit/checks.go::stuckTerminatingThresholdWarning
	// (5min) and stuckTerminatingThresholdDanger (30min). The audit and
	// the per-resource Issue must agree on what counts as "stuck" so an
	// operator scanning both surfaces sees consistent severity.
	switch {
	case age >= 30*time.Minute:
		severity = SeverityAlert
		msg = fmt.Sprintf("Deletion has been pending for %s — finalizers are blocking cleanup.", rel)
		action = "The owning controller of the finalizer is likely unhealthy. Check its pod logs and DNS / network reachability."
	case age >= 5*time.Minute:
		severity = SeverityWarning
		msg = fmt.Sprintf("Deletion has been pending for %s.", rel)
		action = "Wait a few more minutes; if it remains stuck, investigate the finalizer's owning controller."
	}
	if len(finalizers) > 0 {
		msg += " Finalizers: " + strings.Join(finalizers, ", ") + "."
	}

	// Enrich with controller health when we can identify the finalizer
	// owner. The resolver may return "" — typical when the finalizer
	// isn't in our catalog or the controller's pods aren't in the
	// expected namespace — in which case we fall through to the
	// finalizer-list-only message above.
	//
	// We probe each finalizer in order and surface the *first* signal
	// that's actually informative. Stopping at the first hit (rather
	// than concatenating all) keeps the Cause text scannable; finalizers
	// after the first are usually controller-specific cleanup keys that
	// follow the lead controller's lifecycle.
	if resolver != nil && severity != SeverityInfo {
		// Only enrich at warning+; the <5min case isn't actionable yet, and a
		// controller-health line on a healthy controller would overstate urgency.
		for _, f := range finalizers {
			if status := resolver.FinalizerOwnerStatus(f, root); status != "" {
				return &Issue{
					Severity: severity,
					Scope:    ScopeLifecycle,
					Reason:   reason,
					Message:  msg,
					Action:   action,
					Cause:    status,
					Stuck:    severity == SeverityAlert,
				}
			}
		}
	}

	return &Issue{
		Severity: severity,
		Scope:    ScopeLifecycle,
		Reason:   reason,
		Message:  msg,
		Action:   action,
		Stuck:    severity == SeverityAlert,
	}
}

// DetectArgoRollbackRevisionMismatch shares the completed rollback outcome and
// recovery guidance between detail insights and cluster Issues.
func DetectArgoRollbackRevisionMismatch(root *unstructured.Unstructured) *Issue {
	target, applied := gitops.ArgoRollbackRevisionMismatch(root)
	if target == "" {
		return nil
	}
	message := "Last rollback deployed different sources or revisions than requested."
	cause := "Argo CD applied different sources or revisions than the rollback requested."
	if applied == "" {
		message = "Couldn't verify the rollback target " + target + ": Argo CD recorded no applied revisions."
		cause = "A successful operation phase alone does not prove Argo CD applied the rollback target."
	} else if target != applied {
		message = "Last rollback deployed different sources or revisions than requested: " + applied + " instead of " + target + "."
	}
	action := "Sync the intended revision manually, then check the result."
	currentSources, _, _ := unstructured.NestedSlice(root.Object, "spec", "sources")
	sync, _, _ := unstructured.NestedMap(root.Object, "status", "operationState", "operation", "sync")
	if len(currentSources) > 0 || sync["revisions"] != nil || sync["sources"] != nil {
		cause = "On Argo CD before 2.12, rolling back across a single-source/multi-source change or to a multi-source entry can deploy the wrong revisions or render nothing; with prune, it can delete resources."
		action = "Check the Argo CD controller version, then sync the intended revision manually."
	}
	return &Issue{
		Severity: SeverityWarning,
		Scope:    ScopeOperation,
		Reason:   "RollbackRevisionMismatch",
		Message:  message,
		Cause:    cause,
		Action:   action,
	}
}

// detectStuckDriftLoop emits a critical issue when an Argo Application is
// in the "applied successfully but still drifted" state — the case where
// the user stares at the OutOfSync badge for hours wondering why nothing
// happens. All conditions must hold:
//
//   - sync status is OutOfSync (drift exists)
//   - last operation phase is Succeeded (the apply itself didn't error)
//   - applied revisions and sources match the current desired comparison
//   - auto-sync is enabled (so Argo *would* fix it if it could)
//   - reconciledAt is recent (controller is actively trying)
//
// Together these mean: Argo is doing exactly what it's configured to do,
// the apply call returns success, and yet the live state immediately
// reverts to differing from desired. The cause is almost always a
// controller or admission webhook mutating the resource after each apply
// — the "perpetual drift loop" pattern.
//
// Returns nil when conditions don't match — callers append only on hit.
func detectStuckDriftLoop(root *unstructured.Unstructured) *Issue {
	sync, _, _ := unstructured.NestedString(root.Object, "status", "sync", "status")
	if sync != "OutOfSync" {
		return nil
	}
	phase, _, _ := unstructured.NestedString(root.Object, "status", "operationState", "phase")
	if phase != "Succeeded" || !gitops.ArgoAppliedCurrentComparison(root) {
		return nil
	}
	// Self-heal must be ON for this to be a *loop*: the premise is that Argo
	// keeps applying and the resource keeps reverting. With self-heal off, a
	// persistent post-sync drift is expected (Argo won't re-correct it) —
	// that's detectAutoDriftSelfHealOff's case, not a webhook fighting Argo.
	if _, selfHeal := argoAutoSync(root); !selfHeal {
		return nil
	}
	reconciledAt, _, _ := unstructured.NestedString(root.Object, "status", "reconciledAt")
	if reconciledAt == "" {
		return nil
	}
	t, err := time.Parse(time.RFC3339, reconciledAt)
	if err != nil {
		log.Printf("[gitops/insights] detectStuckDriftLoop: unparseable status.reconciledAt %q on %s/%s: %v", reconciledAt, root.GetNamespace(), root.GetName(), err)
		return nil
	}
	// 30-minute window: long enough to allow a legitimate slow-converging
	// resource (think CRDs that take many seconds per reconcile) to settle,
	// short enough that "haven't reconciled in an hour" doesn't trigger the
	// stuck banner — that case is a different problem (controller down).
	if time.Since(t) > 30*time.Minute {
		return nil
	}
	return &Issue{
		Severity: SeverityCritical,
		Scope:    ScopeOperation,
		Reason:   "StuckDriftLoop",
		Message:  "Sync succeeded but the application is still OutOfSync. A controller or admission webhook is likely mutating resources after each apply.",
		Cause:    "Auto-sync ran successfully and the controller's last reconcile is recent, but live state keeps diverging from Git. Common causes: a mutating admission webhook adds defaults Argo isn't told to ignore; a sibling controller (e.g. Karpenter, Istio, cert-manager) writes back into spec; the Git manifest uses a deprecated API schema that the conversion webhook rewrites.",
		Action:   "Open Changes to see the per-resource drift. Match the diff against your Git manifest, the resource's controller, and any mutating webhooks.",
		Stuck:    true,
	}
}

// detectManualDriftWithoutAutoSync emits a warning when an Argo Application
// is OutOfSync but auto-sync is disabled. The user otherwise has no signal
// that the drift won't resolve on its own. A proven successful rollback
// instead records the last rollback without claiming the live state is unchanged.
//
// Returns nil when conditions don't match — caller appends only on hit.
func detectManualDriftWithoutAutoSync(root *unstructured.Unstructured, syncHazard string) *Issue {
	sync, _, _ := unstructured.NestedString(root.Object, "status", "sync", "status")
	if sync != "OutOfSync" {
		return nil
	}
	// Only fire when auto-sync is genuinely off. "Auto" with self-heal off is
	// the adjacent case — Argo applies on a new Git revision but won't correct
	// live drift — and is owned by detectAutoDriftSelfHealOff.
	if describeArgoAutoSync(root) != "Manual" {
		return nil
	}
	if revision, username := gitops.ArgoSuccessfulRollback(root); revision != "" {
		message := "Last rollback deployed " + revision
		if username != "" {
			message += " by " + username
		}
		revisionLabel := "revision"
		if revisions, _, _ := unstructured.NestedStringSlice(root.Object, "status", "operationState", "syncResult", "revisions"); len(revisions) > 1 {
			revisionLabel = "revisions"
		}
		issue := &Issue{
			Severity: SeverityInfo,
			Scope:    ScopeOperation,
			Reason:   "RolledBack",
			Message:  message + ".",
			Cause:    "Auto-sync is off, so Argo won't deploy a new revision on its own. Changes made since the rollback can also show as drift. Syncing or enabling auto-sync deploys the current " + revisionLabel + " and can undo the rollback.",
		}
		if syncHazard != "" {
			issue.Action = syncHazard
			issue.Hazard = syncHazard
		}
		return issue
	}
	issue := &Issue{
		Severity: SeverityWarning,
		Scope:    ScopeOperation,
		Reason:   "ManualDrift",
		Message:  "Application is OutOfSync and auto-sync is disabled — nothing will reconcile until you click Sync.",
		Action:   "Open Changes to review the per-resource diff, then click Sync to apply. Enable auto-sync if you want this to fix itself going forward.",
	}
	if syncHazard != "" {
		issue.Message = "Application is OutOfSync and auto-sync is disabled, so nothing reconciles it automatically."
		issue.Action = syncHazard
		issue.Hazard = syncHazard
	}
	return issue
}

// argoSyncHazardAction is the guidance that replaces "click Sync" when a sync
// would prune every managed resource or take resources over from another
// Application; empty when a sync is a plain reconcile.
func argoSyncHazardAction(root *unstructured.Unstructured) string {
	return diagnose.ArgoSyncHazardAction(gitops.ArgoSyncPrunesEverything(root), gitops.ArgoComparisonFailed(root), gitops.ArgoConditionMessages(root, "SharedResourceWarning"))
}

// argoEmptyRenderGuardActive reports whether Argo's empty-render auto-sync
// guard currently holds: the condition is present and Argo's own test for it
// still passes.
func argoEmptyRenderGuardActive(root *unstructured.Unstructured) bool {
	if !gitops.ArgoEmptyRenderGuardHolds(root) {
		return false
	}
	for _, msg := range gitops.ArgoConditionMessages(root, argoSyncErrorConditionType) {
		if diagnose.ParseArgoOperationError(msg).Reason == diagnose.ReasonAutoSyncBlockedEmpty {
			return true
		}
	}
	return false
}

// argoAutoSync reports whether automated sync is enabled and, if so, whether
// selfHeal is enabled within it. The two booleans distinguish the three
// drift-reconciliation postures: manual (!automated), auto-deploy-only
// (automated && !selfHeal), and auto-heal (automated && selfHeal).
func argoAutoSync(root *unstructured.Unstructured) (automated, selfHeal bool) {
	if !gitops.ArgoAutoSyncEnabled(root) {
		return false, false
	}
	m, _, _ := unstructured.NestedMap(root.Object, "spec", "syncPolicy", "automated")
	v, _ := m["selfHeal"].(bool)
	return true, v
}

// detectAutoDriftSelfHealOff emits a warning when an Argo Application is
// OutOfSync with auto-sync configured but self-heal disabled. In that posture
// Argo deploys new Git revisions but never corrects drift in the live cluster,
// so the app sits OutOfSync indefinitely with nothing to reconcile it. Without
// this the operator sees drift under "auto-sync" and reasonably assumes it will
// self-correct — it won't. Manual mode is owned by detectManualDriftWithoutAutoSync;
// self-heal on is owned by detectStuckDriftLoop (or reconciles on its own).
//
// Returns nil when conditions don't match — caller appends only on hit.
func detectAutoDriftSelfHealOff(root *unstructured.Unstructured, syncHazard string) *Issue {
	sync, _, _ := unstructured.NestedString(root.Object, "status", "sync", "status")
	if sync != "OutOfSync" {
		return nil
	}
	automated, selfHeal := argoAutoSync(root)
	if !automated || selfHeal {
		return nil
	}
	issue := &Issue{
		Severity: SeverityWarning,
		Scope:    ScopeOperation,
		Reason:   "SelfHealDisabled",
		Message:  "Application is OutOfSync and self-heal is disabled — auto-sync deploys new Git revisions but won't correct drift in the live cluster, so it will stay OutOfSync until you sync.",
		Action:   "Open Changes to review the per-resource diff, then click Sync. Enable self-heal on the sync policy if you want Argo to auto-correct drift going forward.",
	}
	if syncHazard != "" {
		issue.Message = "Application is OutOfSync and self-heal is disabled — auto-sync deploys new Git revisions but won't correct drift in the live cluster."
		issue.Action = syncHazard
		issue.Hazard = syncHazard
	}
	return issue
}

// argoSyncErrorConditionType also covers blocked auto-sync, where no operation ran.
const argoSyncErrorConditionType = "SyncError"

// argoApplicationConditions extracts Argo Application status.conditions[]
// into Issues. Argo conditions are how the controller signals app-level
// problems that aren't tied to a specific operation: ComparisonError when
// the source can't be loaded (bad repo, missing revision), InvalidSpecError
// when the Application spec itself is broken, OrphanedResourceWarning when
// children outside the inventory exist, etc.
//
// Severity mapping follows the convention in the Argo source: types ending
// in "Error" are critical; "Warning" types are warning; everything else is
// info. We elide condition types we don't recognize when the message is
// also empty — they're often controller-internal noise.
func argoApplicationConditions(root *unstructured.Unstructured, syncHazard string) []Issue {
	raw, _, _ := unstructured.NestedSlice(root.Object, "status", "conditions")
	if len(raw) == 0 {
		return nil
	}
	out := make([]Issue, 0, len(raw))
	for _, item := range raw {
		m, ok := item.(map[string]any)
		if !ok {
			continue
		}
		typ := gitops.StringValue(m["type"])
		msg, rawMsg := diagnose.CleanArgoControllerMessageWithRaw(gitops.StringValue(m["message"]))
		if typ == "" && msg == "" {
			continue
		}
		severity := SeverityInfo
		switch tok, _ := diagnose.SeverityForConditionType(typ); tok {
		case "critical":
			severity = SeverityCritical
		case "warning":
			severity = SeverityWarning
		}
		cause, hazard := "", ""
		if typ == "SharedResourceWarning" {
			hazard = syncHazard
		}
		guidance := diagnose.ArgoConditionGuidance(typ, gitops.ArgoCLIName(root), []string{msg})
		actionPrimary, actionSecondaryTitle, actionSecondary := guidance.ActionPrimary, guidance.ActionSecondaryTitle, guidance.ActionSecondary
		var remediation *Remediation
		action := guidance.Action
		if typ == argoSyncErrorConditionType {
			parsed := diagnose.ParseArgoOperationError(msg)
			createNamespaceSet := gitops.ArgoCreateNamespaceEnabled(root)
			parsed = diagnose.WithCreateNamespaceState(parsed, createNamespaceSet)
			if parsed.Reason != "" {
				if parsed.Reason == diagnose.ReasonAutoSyncBlockedEmpty && !gitops.ArgoEmptyRenderGuardHolds(root) {
					continue
				}
				typ = parsed.Reason
			}
			if parsed.Summary != "" {
				rawMsg = fallback(rawMsg, msg)
				msg = parsed.Summary
			}
			if !gitops.IsInClusterDestination(root) || gitops.ArgoAppliesElsewhere(root) {
				var remoteAction string
				parsed, remoteAction = diagnose.WithoutLocalRemediation(parsed, createNamespaceSet)
				if remoteAction != "" {
					action = remoteAction
				}
			}
			hazard = syncHazard
			cause = parsed.Cause
			actionPrimary, actionSecondaryTitle, actionSecondary = parsed.ActionPrimary, parsed.ActionSecondaryTitle, parsed.ActionSecondary
			remediation = remediationFromParsed(parsed)
			if parsed.Action != "" {
				action = parsed.Action
			}
			// A failed sync's advice is to retry, and its one-click fix syncs.
			if syncHazard != "" && parsed.Reason != diagnose.ReasonAutoSyncBlockedEmpty {
				action = syncHazard
				remediation = nil
			}
		}
		out = append(out, Issue{
			Remediation:          remediation,
			Severity:             severity,
			Scope:                ScopeCondition,
			Cause:                cause,
			Hazard:               hazard,
			Reason:               fallback(typ, "Condition"),
			Message:              fallback(msg, typ),
			RawMessage:           rawMsg,
			Action:               action,
			ActionPrimary:        actionPrimary,
			ActionSecondaryTitle: actionSecondaryTitle,
			ActionSecondary:      actionSecondary,
		})
	}
	return out
}