package main

import (
	"encoding/json"
	"fmt"
	"sort"
	"strings"
	"sync"
	"time"

	"charm.land/bubbles/v2/spinner"
	"charm.land/bubbles/v2/table"
	tea "charm.land/bubbletea/v2"
	cblog "github.com/charmbracelet/log"
	"github.com/darksworm/argonaut/pkg/api"
	"github.com/darksworm/argonaut/pkg/autocomplete"
	"github.com/darksworm/argonaut/pkg/config"
	apperrors "github.com/darksworm/argonaut/pkg/errors"
	"github.com/darksworm/argonaut/pkg/model"
	"github.com/darksworm/argonaut/pkg/services"
	"github.com/darksworm/argonaut/pkg/tui"
	"github.com/darksworm/argonaut/pkg/tui/clipboard"
	"github.com/darksworm/argonaut/pkg/tui/listnav"
	"github.com/darksworm/argonaut/pkg/tui/selection"
	"github.com/darksworm/argonaut/pkg/tui/treeview"
)

// Model represents the main Bubbletea model containing all application state
type Model struct {
	// Core application state
	state *model.AppState

	// Services
	argoService       services.ArgoApiService
	navigationService services.NavigationService
	statusService     services.StatusService
	updateService     services.UpdateService

	// Configuration
	config *config.ArgonautConfig

	// Interactive input components using bubbles
	inputComponents *InputComponentState

	// Autocomplete engine for command suggestions
	autocompleteEngine *autocomplete.AutocompleteEngine

	// Internal flags
	ready bool
	err   error

	// Watch channel for Argo events
	watchChan chan services.ArgoApiEvent
	// Closed when the current app watch forwarder stops.
	watchDone chan struct{}
	// Cleanup callback for active applications watcher
	appWatchCleanup func()

	// Watch cleanup function for stopping the current watch stream
	watchCleanup func()

	// Watch generation counter — incremented on each watch restart to prevent
	// stale batch handlers from spawning duplicate consumers
	watchGeneration int

	// Current project scope the watch stream is filtered by (nil = unfiltered).
	// Compared with SelectionState.ScopeProjects to detect when a restart is needed.
	watchScopeProjects []string

	// Debounce version counter for scope changes (prevents thrashing during rapid navigation)
	scopeVersion int

	// Monotonic sequence for start-watch attempts; guards against late/stale starts.
	watchStartSequence int

	// Resource version from last list call (for watch coordination)
	lastResourceVersion string

	// bubbles spinner for loading
	spinner spinner.Model

	// bubbles tables for all views
	appsTable       table.Model
	clustersTable   table.Model
	namespacesTable table.Model
	projectsTable   table.Model

	// Bubble Tea program reference for terminal hand-off (pager integration)
	program *tea.Program
	inPager bool

	// Tree view component
	treeView *treeview.TreeView

	// Tree watch internal channel delivery
	treeStream chan model.ResourceTreeStreamMsg

	// Tree loading overlay state
	treeLoading bool

	// List navigators for all scrollable lists
	listNav     *listnav.ListNavigator // Main list (apps, clusters, namespaces, projects)
	treeNav     *listnav.ListNavigator // Tree view
	themeNav    *listnav.ListNavigator // Theme selection modal
	rollbackNav *listnav.ListNavigator // Rollback history modal

	// Cleanup callbacks for active tree watchers
	treeWatchCleanups []func()

	// Debug: render counter
	renderCount int

	// Theme selection helpers
	themeOptions []themeOption

	// k9s context selection state
	k9sContextOptions   []string // Available kubeconfig contexts
	k9sContextSelected  int      // Selected index in context list
	k9sPendingKind      string   // Resource kind to open in k9s
	k9sPendingNamespace string   // Resource namespace to open in k9s
	k9sPendingName      string   // Resource name to filter in k9s

	// Text selection state for mouse-based copy
	selection *selection.Selection

	// Last rendered content (plain text, for selection extraction)
	lastRenderedLines []string

	// Pending default_view scope to validate after apps load
	pendingDefaultViewScope *defaultViewScope

	// Context switching state
	argoConfigPath     string // Path to ArgoCD CLI config (for re-reads on switch)
	currentContextName string // Active ArgoCD context name
	switchEpoch        int    // Incremented on each context switch; captured by async closures
}

func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {

	// Terminal/System messages
	case tea.WindowSizeMsg:
		m.state.Terminal.Rows = msg.Height
		m.state.Terminal.Cols = msg.Width
		if m.treeView != nil {
			m.treeView.SetSize(m.contentInnerWidth(), msg.Height)
		}
		if !m.ready {
			m.ready = true
			return m, func() tea.Msg {
				return model.StatusChangeMsg{Status: "Ready"}
			}
		}
		return m, nil

	case tea.KeyMsg:
		return m.handleKeyMsg(msg)

	case tea.PasteMsg:
		// Handle clipboard paste events

		// Handle based on current mode
		if m.state.Mode == model.ModeSearch {
			// For search mode, append pasted text to current search
			currentValue := m.inputComponents.GetSearchValue()
			newValue := currentValue + msg.Content
			m.inputComponents.SetSearchValue(newValue)
			m.state.UI.SearchQuery = newValue
			// Clamp selection within new filtered results
			m.state.Navigation.SelectedIdx = m.navigationService.ValidateBounds(
				m.state.Navigation.SelectedIdx,
				len(m.getVisibleItems()),
			)
			return m, nil
		} else if m.state.Mode == model.ModeCommand {
			// For command mode, append pasted text to current command
			currentValue := m.inputComponents.GetCommandValue()
			newValue := currentValue + msg.Content
			m.inputComponents.SetCommandValue(newValue)
			m.state.UI.Command = newValue
			m.state.UI.CommandInvalid = false
			return m, nil
		}
		return m, nil

	case tea.MouseClickMsg:
		return m.handleMouseClickMsg(msg)

	case tea.MouseMotionMsg:
		return m.handleMouseMotionMsg(msg)

	case tea.MouseReleaseMsg:
		return m.handleMouseReleaseMsg(msg)

	case clearCopiedStatusMsg:
		m.state.UI.SelectionCopied = false
		return m, nil

	case clipboard.CopyMsg:
		// Clipboard copy completed (success or failure logged elsewhere)
		return m, nil

	// Tree stream messages from watcher goroutine
	case model.ResourceTreeStreamMsg:
		cblog.With("component", "ui").Debug("Processing tree stream message", "app", msg.AppName, "hasData", len(msg.TreeJSON) > 0)
		if len(msg.TreeJSON) > 0 && m.treeView != nil && m.state.Navigation.View == model.ViewTree {
			var tree api.ResourceTree
			if err := json.Unmarshal(msg.TreeJSON, &tree); err == nil {
				cblog.With("component", "ui").Debug("Updating tree view", "app", msg.AppName, "nodes", len(tree.Nodes))
				m.treeView.UpsertAppTree(msg.AppName, &tree)
			} else {
				cblog.With("component", "ui").Error("Failed to unmarshal tree", "err", err, "app", msg.AppName)
			}
		}
		// Any tree stream activity implies data is arriving; clear loading overlay
		m.treeLoading = false
		return m, m.consumeTreeEvent()

	// Tree watch started (store cleanup)
	case treeWatchStartedMsg:
		if msg.cleanup != nil {
			m.treeWatchCleanups = append(m.treeWatchCleanups, msg.cleanup)
			m.statusService.Set("Watching tree…")
		}
		return m, nil

		// Spinner messages
	case spinner.TickMsg:
		if m.inPager {
			// Suspend spinner updates while pager owns the terminal
			return m, nil
		}
		var cmd tea.Cmd
		m.spinner, cmd = m.spinner.Update(msg)
		return m, cmd

	// Navigation messages
	case model.SetViewMsg:
		m.state.Navigation.View = msg.View
		return m, nil

	case model.SetSelectedIdxMsg:
		// Keep selection within bounds of currently visible items
		m.state.Navigation.SelectedIdx = m.navigationService.ValidateBounds(
			msg.SelectedIdx,
			len(m.getVisibleItems()),
		)
		return m, nil

	case model.ResetNavigationMsg:
		m.state.Navigation.SelectedIdx = 0
		if msg.View != nil {
			m.state.Navigation.View = *msg.View
		}
		return m, nil

	// Selection messages
	case model.SetSelectedAppsMsg:
		m.state.Selections.SelectedApps = msg.Apps
		return m, nil

	case model.ClearAllSelectionsMsg:
		m.state.Selections = *model.NewSelectionState()
		// Phase 4: Check if project scope changed → restart watch with project filter
		return m, m.maybeRestartWatchForScope()

	// UI messages
	case model.SetSearchQueryMsg:
		m.state.UI.SearchQuery = msg.Query
		return m, nil

	case model.SetActiveFilterMsg:
		m.state.UI.ActiveFilter = msg.Filter
		return m, nil

	case model.SetCommandMsg:
		m.state.UI.Command = msg.Command
		return m, nil

	case model.ClearFiltersMsg:
		m.state.UI.SearchQuery = ""
		m.state.UI.ActiveFilter = ""
		return m, nil

	case model.SetAPIVersionMsg:
		m.state.APIVersion = msg.Version
		return m, nil

		// Mode messages
	case model.SetModeMsg:
		oldMode := m.state.Mode
		cblog.With("component", "model").Info("SetModeMsg received",
			"old_mode", oldMode,
			"new_mode", msg.Mode)

		// If the user is browsing the contexts picker, suppress loading-pipeline
		// mode transitions (ModeLoading, ModeConnectionError, ModeAuthRequired)
		// so the picker stays visible. Still trigger API loads in the background.
		if m.state.Navigation.View == model.ViewContexts {
			switch msg.Mode {
			case model.ModeLoading:
				if oldMode == model.ModeLoading {
					return m, nil
				}
				m.state.Mode = model.ModeLoading
				cblog.With("component", "model").Info("Triggering initial load (suppressed ModeLoading overlay for ViewContexts)")
				return m, m.startLoadingApplications()
			case model.ModeConnectionError, model.ModeAuthRequired, model.ModeError:
				cblog.With("component", "model").Info("Suppressed mode change for ViewContexts",
					"suppressed_mode", msg.Mode)
				return m, nil
			}
		}

		// Handle mode transitions
		if msg.Mode == model.ModeLoading && oldMode != model.ModeLoading {
			m.state.Mode = msg.Mode
			cblog.With("component", "model").Info("Triggering initial load for ModeLoading")
			return m, m.startLoadingApplications()
		}

		m.state.Mode = msg.Mode

		// If entering diff mode with content available, show in external pager
		if msg.Mode == model.ModeDiff && m.state.Diff != nil && len(m.state.Diff.Content) > 0 && !m.state.Diff.Loading {
			title := m.state.Diff.Title
			body := strings.Join(m.state.Diff.Content, "\n")
			return m, m.openTextPager(title, body)
		}

		return m, nil

	// Data messages
	case model.SetAppsMsg:
		m.state.Apps = msg.Apps
		m.state.Index = model.BuildAppIndex(m.state.Apps)
		return m, nil

	case model.SetServerMsg:
		m.cleanupAppWatcher()
		m.state.Server = msg.Server
		// Also fetch API version and start watching
		return m, tea.Batch(m.startWatchingApplications(), m.fetchAPIVersion())

	case watchStartedMsg:
		// Ignore stale/late watch start completions and stop their streams.
		if msg.startSequenceNum != m.watchStartSequence {
			cblog.With("component", "watch").Debug("watchStartedMsg: ignoring stale start",
				"msg_start_seq", msg.startSequenceNum,
				"current_start_seq", m.watchStartSequence)
			if msg.cleanup != nil {
				msg.cleanup()
			}
			return m, nil
		}

		// New stream is confirmed; clean up the previous watch (forwarding goroutine + upstream).
		m.cleanupAppWatcher()

		// Activate generation/scope for the new stream.
		m.watchGeneration = msg.generation
		m.watchScopeProjects = append([]string(nil), msg.scopeProjects...)

		// Store cleanup function for scoped watch restarts (Phase 4)
		m.watchCleanup = msg.cleanup

		// Set up the watch channel with proper forwarding.
		outCh := make(chan services.ArgoApiEvent, 100)
		done := make(chan struct{})
		m.watchChan = outCh
		m.watchDone = done
		stopForwarding := make(chan struct{})
		var stopOnce sync.Once
		upstreamCleanup := msg.cleanup
		m.appWatchCleanup = func() {
			stopOnce.Do(func() {
				close(stopForwarding)
			})
			if upstreamCleanup != nil {
				upstreamCleanup()
			}
		}

		cblog.With("component", "watch").Debug("watchStartedMsg: setting up watch channel forwarding",
			"generation", m.watchGeneration)
		go func() {
			defer close(done)
			cblog.With("component", "watch").Debug("watchStartedMsg: goroutine started")
			eventCount := 0
			for {
				select {
				case <-stopForwarding:
					cblog.With("component", "watch").Debug("watchStartedMsg: stop signal received")
					return
				case ev, ok := <-msg.eventChan:
					if !ok {
						cblog.With("component", "watch").Debug("watchStartedMsg: eventChan closed")
						return
					}
					eventCount++
					cblog.With("component", "watch").Debug("watchStartedMsg: forwarding event",
						"event_number", eventCount,
						"type", ev.Type)
					select {
					case outCh <- ev:
					case <-stopForwarding:
						cblog.With("component", "watch").Debug("watchStartedMsg: stop while forwarding event")
						return
					}
				}
			}
		}()
		// Start consuming events (batched)
		return m, m.consumeWatchEvents()

	// API Event messages
	case model.AppsLoadedMsg:
		// Gate by switch epoch — discard messages from a previous context
		if msg.SwitchEpoch != m.switchEpoch {
			cblog.With("component", "model").Debug("AppsLoadedMsg: ignoring stale epoch",
				"msg_epoch", msg.SwitchEpoch, "current_epoch", m.switchEpoch)
			return m, nil
		}
		cblog.With("component", "model").Info("AppsLoadedMsg received",
			"apps_count", len(msg.Apps),
			"watchChan_nil", m.watchChan == nil,
			"resourceVersion", msg.ResourceVersion)
		m.state.Apps = msg.Apps
		m.state.Index = model.BuildAppIndex(m.state.Apps)
		// Store resource version for watch coordination
		if msg.ResourceVersion != "" {
			m.lastResourceVersion = msg.ResourceVersion
		}
		// Turn off initial loading modal if it was active
		m.state.Modals.InitialLoading = false

		// Validate pending default_view scope against loaded data
		m.validateDefaultViewScope()

		// Determine which mode to transition to
		targetMode := model.ModeNormal
		if m.state.Modals.DefaultViewWarning != nil {
			targetMode = model.ModeDefaultViewWarning
		}

		// Only start watching if we haven't already started
		// (watchChan is set when watch starts)
		if m.watchChan == nil {
			cblog.With("component", "model").Info("Starting watch as watchChan is nil")
			// Start watching for app updates after initial load
			return m, tea.Batch(
				func() tea.Msg { return model.SetModeMsg{Mode: targetMode} },
				m.startWatchingApplications(),
			)
		}
		// Watch is already running — the batch handler maintains the chain.
		// Do NOT call consumeWatchEvents() here to avoid duplicate consumers.
		return m, func() tea.Msg { return model.SetModeMsg{Mode: targetMode} }

	case model.AppsBatchUpdateMsg:
		// Gate by switch epoch — discard entire batch from a previous context
		if msg.SwitchEpoch != m.switchEpoch {
			cblog.With("component", "model").Debug("AppsBatchUpdateMsg: ignoring stale epoch",
				"msg_epoch", msg.SwitchEpoch, "current_epoch", m.switchEpoch)
			return m, nil
		}
		deletesApplied := 0
		// Preferred path: apply ordered operations to preserve stream semantics.
		if len(msg.Operations) > 0 {
			for _, op := range msg.Operations {
				switch op.Type {
				case model.AppBatchOperationUpdate:
					if op.Update != nil {
						m.applyBatchAppUpdate(*op.Update)
					}
				case model.AppBatchOperationDelete:
					if op.Delete != "" && m.applyBatchAppDelete(op.Delete) {
						deletesApplied++
					}
				}
			}
		} else {
			// Backward-compatible fallback for older/non-ordered producers.
			for _, upd := range msg.Updates {
				m.applyBatchAppUpdate(upd)
			}
			for _, name := range msg.Deletes {
				if m.applyBatchAppDelete(name) {
					deletesApplied++
				}
			}
		}
		m.state.Index = model.BuildAppIndex(m.state.Apps)
		// Adjust selection bounds after deletes
		if deletesApplied > 0 {
			visibleItems := m.getVisibleItemsForCurrentView()
			if m.state.Navigation.SelectedIdx >= len(visibleItems) && len(visibleItems) > 0 {
				m.state.Navigation.SelectedIdx = len(visibleItems) - 1
			}
		}
		cblog.With("component", "watch").Debug("AppsBatchUpdateMsg processed",
			"updates", len(msg.Updates),
			"deletes", len(msg.Deletes),
			"total_apps", len(m.state.Apps),
			"batch_gen", msg.Generation,
			"current_gen", m.watchGeneration)
		// Continue watching + re-dispatch immediate event if present.
		// Only continue the watch chain if this batch is from the current generation;
		// stale batches from a pre-restart watch must not spawn duplicate consumers.
		var cmds []tea.Cmd
		if msg.Generation == m.watchGeneration {
			cmds = append(cmds, m.consumeWatchEvents())
		}
		if msg.Immediate != nil {
			imm := msg.Immediate
			cmds = append(cmds, func() tea.Msg { return imm })
		}
		return m, tea.Batch(cmds...)

	case model.StatusChangeMsg:
		// Now safe to log since we're using file logging
		m.statusService.Set(msg.Status)

		// Clear diff loading state for diff-related status messages
		if (msg.Status == "No diffs" || msg.Status == "No differences") && m.state.Diff != nil {
			m.state.Diff.Loading = false
		}

		return m, nil

	case model.ResourceTreeLoadedMsg:
		// Gate by switch epoch
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		// Populate tree view with loaded data (single or multi-app)
		if m.treeView != nil && len(msg.TreeJSON) > 0 {
			var tree api.ResourceTree
			if err := json.Unmarshal(msg.TreeJSON, &tree); err == nil {
				m.treeView.SetAppMeta(msg.AppName, msg.Health, msg.Sync)
				m.treeView.UpsertAppTree(msg.AppName, &tree)

				// Apply resource sync statuses from Application.status.resources
				if len(msg.ResourcesJSON) > 0 {
					var resources []api.ResourceStatus
					if json.Unmarshal(msg.ResourcesJSON, &resources) == nil {
						m.treeView.SetResourceStatuses(msg.AppName, resources)
					}
				}

				// Apply current sort config to the newly loaded tree
				m.treeView.SetSort(m.state.UI.Sort)
			}
			// Reset cursor for tree view
			m.state.Navigation.SelectedIdx = 0
			m.statusService.Set("Tree loaded")
		}
		// Clear loading overlay once initial tree is loaded
		m.treeLoading = false
		return m, nil

		// removed: resources list loader

		// Old spinner TickMsg removed - now using bubbles spinner

	case model.StructuredErrorMsg:
		// Gate by switch epoch
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		// Handle structured errors with proper error state management
		if msg.Error != nil {
			errorMsg := fmt.Sprintf("Error: %s", msg.Error.Message)
			if msg.Error.UserAction != "" {
				errorMsg += fmt.Sprintf(" - %s", msg.Error.UserAction)
			}
			m.statusService.Error(errorMsg)

			// Debug: Log structured error details
			cblog.With("component", "tui").Debug("StructuredErrorMsg",
				"category", msg.Error.Category, "code", msg.Error.Code, "message", msg.Error.Message)

			// Update error state so the error view can show full details
			tui.UpdateAppErrorState(m.state, msg.Error)
		}

		// Clear any loading states that might be active
		if m.state.Diff != nil {
			m.state.Diff.Loading = false
		}
		if m.state.Modals.ConfirmSyncLoading {
			m.state.Modals.ConfirmSyncLoading = false
			m.state.Modals.ConfirmTarget = nil
			// Set mode to error to show the error immediately
			m.state.Mode = model.ModeError
		}
		// Turn off initial loading modal if it was active
		m.state.Modals.InitialLoading = false

		// If we were in the loading mode when the structured error arrived, switch to error view.
		// Don't override the mode if user is browsing contexts — let them pick a working one.
		if msg.Error != nil && m.state.Navigation.View != model.ViewContexts {
			if msg.Error.Category == apperrors.ErrorAuth {
				m.state.Mode = model.ModeAuthRequired
			} else if m.state.Mode == model.ModeLoading {
				m.state.Mode = model.ModeError
			}
		}

		// If we have a structured error with high severity, switch to error mode
		if msg.Error != nil && msg.Error.Severity == apperrors.SeverityHigh && m.state.Navigation.View != model.ViewContexts {
			m.state.Mode = model.ModeError
		}

		return m, nil

	case model.ApiErrorMsg:
		// Gate by switch epoch
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		m.cleanupAppWatcher()
		// If we're already in auth-required mode, suppress generic API errors to avoid
		// overriding the auth-required view with a generic error panel.
		if m.state.Mode == model.ModeAuthRequired {
			return m, nil
		}
		// Log error and store structured error in state for display
		fullErrorMsg := fmt.Sprintf("API Error: %s", msg.Message)
		if msg.StatusCode > 0 {
			fullErrorMsg = fmt.Sprintf("API Error (%d): %s", msg.StatusCode, msg.Message)
		}
		m.statusService.Error(fullErrorMsg)

		// Clear any loading states that might be active
		if m.state.Diff != nil {
			m.state.Diff.Loading = false
		}
		if m.state.Modals.ConfirmSyncLoading {
			m.state.Modals.ConfirmSyncLoading = false
			m.state.Modals.ConfirmTarget = nil
			if m.state.Mode == model.ModeConfirmSync {
				m.state.Mode = model.ModeNormal
			}
		}
		// Turn off initial loading modal if it was active
		m.state.Modals.InitialLoading = false

		// If we were loading tree view, return to apps view
		if m.state.Navigation.View == model.ViewTree {
			m = m.cleanupTreeWatchers()
			m.state.Navigation.View = model.ViewApps
		}

		// Handle rollback-specific errors
		if m.state.Mode == model.ModeRollback {
			// If we're not in an active rollback execution (i.e., not loading), keep error in modal
			if m.state.Rollback != nil && !m.state.Rollback.Loading {
				// Initialize rollback state with error if not exists
				if m.state.Rollback == nil && m.state.Modals.RollbackAppName != nil {
					m.state.Rollback = &model.RollbackState{
						AppName: *m.state.Modals.RollbackAppName,
						Loading: false,
						Error:   msg.Message,
						Mode:    "list",
					}
				} else {
					// Update existing rollback state with error
					m.state.Rollback.Loading = false
					m.state.Rollback.Error = msg.Message
				}
				// Stay in rollback mode to show the error inline
				return m, nil
			}
			// else: in active rollback execution, fall through to generic error screen below
		}

		// Store structured error information in state
		m.state.CurrentError = &model.ApiError{
			Message:    msg.Message,
			StatusCode: msg.StatusCode,
			ErrorCode:  msg.ErrorCode,
			Details:    msg.Details,
			Timestamp:  time.Now().Unix(),
		}

		return m, func() tea.Msg {
			return model.SetModeMsg{Mode: model.ModeError}
		}

	case pauseRenderingMsg:
		m.inPager = true
		return m, nil

	case resumeRenderingMsg:
		m.inPager = false
		return m, nil

	case pagerDoneMsg:
		// Restore pager state
		m.inPager = false

		// If there was an error, display it
		if msg.Err != nil {
			cblog.With("component", "pager").Error("Pager error", "err", msg.Err)
			// Set error state and display the error on screen
			m.state.CurrentError = &model.ApiError{
				Message:    "Pager Error: " + msg.Err.Error(),
				StatusCode: 0,
				ErrorCode:  1001, // Custom error code for pager errors
				Details:    "Failed to open text pager",
				Timestamp:  time.Now().Unix(),
			}
			return m, func() tea.Msg {
				return model.SetModeMsg{Mode: model.ModeError}
			}
		}

		// No error, go back to normal mode
		m.state.Mode = model.ModeNormal
		return m, nil

	case k9sDoneMsg:
		// k9s exited - restore normal mode
		m.inPager = false

		// If there was an error, show error popup
		if msg.Err != nil {
			cblog.With("component", "k9s").Error("k9s error", "err", msg.Err)
			errStr := msg.Err.Error()
			m.state.Modals.K9sError = &errStr
			m.state.Mode = model.ModeK9sError
			return m, nil
		}
		m.state.Mode = model.ModeNormal
		return m, nil

	case model.AuthErrorMsg:
		// Gate by switch epoch
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		m.cleanupAppWatcher()
		// Log error and store in model for display
		m.statusService.Error(msg.Error.Error())
		m.err = msg.Error

		// Turn off initial loading modal if it was active
		m.state.Modals.InitialLoading = false

		// Handle rollback-specific auth errors
		if m.state.Mode == model.ModeRollback {
			// Initialize rollback state with error if not exists
			if m.state.Rollback == nil && m.state.Modals.RollbackAppName != nil {
				m.state.Rollback = &model.RollbackState{
					AppName: *m.state.Modals.RollbackAppName,
					Loading: false,
					Error:   "Authentication required: " + msg.Error.Error(),
					Mode:    "list",
				}
			} else if m.state.Rollback != nil {
				// Update existing rollback state with auth error
				m.state.Rollback.Loading = false
				m.state.Rollback.Error = "Authentication required: " + msg.Error.Error()
			}
			// Stay in rollback mode to show the error
			return m, nil
		}

		return m, tea.Batch(func() tea.Msg { return model.SetModeMsg{Mode: model.ModeAuthRequired} })

	// Navigation update messages
	case model.NavigationUpdateMsg:
		if msg.NewView != nil {
			m.state.Navigation.View = *msg.NewView
		}
		if msg.ScopeClusters != nil {
			m.state.Selections.ScopeClusters = msg.ScopeClusters
		}
		if msg.ScopeNamespaces != nil {
			m.state.Selections.ScopeNamespaces = msg.ScopeNamespaces
		}
		if msg.ScopeProjects != nil {
			m.state.Selections.ScopeProjects = msg.ScopeProjects
		}
		if msg.SelectedApps != nil {
			m.state.Selections.SelectedApps = msg.SelectedApps
		}
		if msg.ShouldResetNavigation {
			m.state.Navigation.SelectedIdx = 0
			m.listNav.Reset()
		}
		// Phase 4: Check if project scope changed → restart watch with project filter
		return m, m.maybeRestartWatchForScope()

	// Phase 4: Scoped streaming — debounced watch restart on project scope change
	case watchScopeDebounceMsg:
		if msg.version != m.scopeVersion {
			// Stale debounce tick (scope changed again since this was scheduled)
			return m, nil
		}
		return m, m.restartWatchWithScope()

	case model.SyncCompletedMsg:
		// Gate by switch epoch
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		// Handle single app sync completion
		if msg.Success {
			m.statusService.Set(fmt.Sprintf("Sync initiated for %s", msg.AppName))

			// Show tree view if watch is enabled
			if m.state.Modals.ConfirmSyncWatch {
				// Close confirm modal/loading state before switching views
				m.state.Modals.ConfirmTarget = nil
				m.state.Modals.ConfirmTargetNamespace = nil
				m.state.Modals.ConfirmSyncLoading = false
				if m.state.Mode == model.ModeConfirmSync {
					m.state.Mode = model.ModeNormal
				}
				// Clean up any existing tree watchers before starting new one
				m.cleanupTreeWatchers()
				// Reset tree view for fresh single-app session
				m.treeView = treeview.NewTreeView(0, 0)
				m.treeView.ApplyTheme(currentPalette)
				m.treeView.SetSize(m.contentInnerWidth(), m.state.Terminal.Rows)
				m.treeNav.Reset() // Reset scroll position
				// Use namespace from message to avoid ambiguity when multiple apps share a name
				ns := ""
				if msg.AppNamespace != nil {
					ns = *msg.AppNamespace
				}
				appObj := model.App{Name: msg.AppName, AppNamespace: msg.AppNamespace}
				if found := m.findAppByNameAndNamespace(msg.AppName, ns); found != nil {
					appObj = *found
				}
				m.state.Navigation.View = model.ViewTree
				m.setTreeApp(appObj)
				return m, tea.Batch(m.startLoadingResourceTree(appObj), m.startWatchingResourceTree(appObj), m.consumeTreeEvent())
			}
		} else {
			m.statusService.Set("Sync cancelled")
		}
		// Close confirm modal/loading state if open (non-watch path)
		m.state.Modals.ConfirmTarget = nil
		m.state.Modals.ConfirmSyncLoading = false
		if m.state.Mode == model.ModeConfirmSync && !m.state.Modals.ConfirmSyncWatch {
			m.state.Mode = model.ModeNormal
		}
		return m, nil

	case model.AppDeleteRequestMsg:
		// Handle application delete request
		m.state.Modals.DeleteLoading = true
		return m, m.deleteApplication(msg)

	case model.AppDeleteSuccessMsg:
		// Gate by switch epoch
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		// Handle successful application deletion
		m.statusService.Set(fmt.Sprintf("Application %s deleted successfully", msg.AppName))

		// Remove app from local state using index for O(1) lookup
		if idx := m.state.Index; idx != nil {
			if i, ok := idx.NameToIndex[msg.AppName]; ok && i < len(m.state.Apps) && m.state.Apps[i].Name == msg.AppName {
				m.state.Apps = append(m.state.Apps[:i], m.state.Apps[i+1:]...)
			} else {
				// Index stale — fall through to linear scan
				for i, app := range m.state.Apps {
					if app.Name == msg.AppName {
						m.state.Apps = append(m.state.Apps[:i], m.state.Apps[i+1:]...)
						break
					}
				}
			}
		} else {
			for i, app := range m.state.Apps {
				if app.Name == msg.AppName {
					m.state.Apps = append(m.state.Apps[:i], m.state.Apps[i+1:]...)
					break
				}
			}
		}
		m.state.Index = model.BuildAppIndex(m.state.Apps)

		// Clear modal state and return to normal mode
		m.state.Mode = model.ModeNormal
		m.state.Modals.DeleteAppName = nil
		m.state.Modals.DeleteAppNamespace = nil
		m.state.Modals.DeleteConfirmationKey = ""
		m.state.Modals.DeleteError = nil
		m.state.Modals.DeleteLoading = false

		// Keep selection at the same index position
		// Only adjust if selection is now beyond the list bounds
		visibleItems := m.getVisibleItemsForCurrentView()
		if m.state.Navigation.SelectedIdx >= len(visibleItems) && len(visibleItems) > 0 {
			m.state.Navigation.SelectedIdx = len(visibleItems) - 1
		}

		return m, nil

	case model.AppDeleteErrorMsg:
		// Handle application deletion error
		m.statusService.Set(fmt.Sprintf("Failed to delete %s: %s", msg.AppName, msg.Error))
		m.state.Modals.DeleteError = &msg.Error
		m.state.Modals.DeleteLoading = false
		// Keep modal open to show error
		return m, nil

	case model.ResourceDeleteSuccessMsg:
		// Handle successful resource deletion
		m.statusService.Set(fmt.Sprintf("Successfully deleted %d resource(s)", msg.Count))

		// Clear selections in tree view
		if m.treeView != nil {
			m.treeView.ClearSelection()
		}

		// Clear modal state and return to normal mode
		m.state.Mode = model.ModeNormal
		m.state.Modals.ResourceDeleteAppName = nil
		m.state.Modals.ResourceDeleteTargets = nil
		m.state.Modals.ResourceDeleteConfirmationKey = ""
		m.state.Modals.ResourceDeleteError = nil
		m.state.Modals.ResourceDeleteLoading = false

		// Trigger a refresh of the affected apps' resource trees
		if len(msg.AppNames) > 0 && m.state.Navigation.View == model.ViewTree {
			var cmds []tea.Cmd
			for _, appName := range msg.AppNames {
				var appObj *model.App
				for i := range m.state.Apps {
					if m.state.Apps[i].Name == appName {
						appObj = &m.state.Apps[i]
						break
					}
				}
				if appObj == nil {
					appObj = &model.App{Name: appName}
				}
				cmds = append(cmds, m.startLoadingResourceTree(*appObj))
			}
			if len(cmds) > 0 {
				return m, tea.Batch(cmds...)
			}
		}
		return m, nil

	case model.ResourceDeleteErrorMsg:
		// Handle resource deletion error
		m.statusService.Set(fmt.Sprintf("Resource deletion failed: %s", msg.Error))
		m.state.Modals.ResourceDeleteError = &msg.Error
		m.state.Modals.ResourceDeleteLoading = false
		// Keep modal open to show error
		return m, nil

	case model.ResourceSyncSuccessMsg:
		// Gate by switch epoch
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		// Handle successful resource sync
		m.statusService.Set(fmt.Sprintf("Successfully synced %d resource(s)", msg.Count))

		// Clear selections in tree view
		if m.treeView != nil {
			m.treeView.ClearSelection()
		}

		// Clear modal state and return to normal mode
		m.state.Mode = model.ModeNormal
		m.state.Modals.ResourceSyncAppName = nil
		m.state.Modals.ResourceSyncTargets = nil
		m.state.Modals.ResourceSyncError = nil
		m.state.Modals.ResourceSyncLoading = false

		// Trigger a refresh of the affected apps' resource trees
		if len(msg.AppNames) > 0 && m.state.Navigation.View == model.ViewTree {
			var cmds []tea.Cmd
			for _, appName := range msg.AppNames {
				var appObj *model.App
				for i := range m.state.Apps {
					if m.state.Apps[i].Name == appName {
						appObj = &m.state.Apps[i]
						break
					}
				}
				if appObj == nil {
					appObj = &model.App{Name: appName}
				}
				cmds = append(cmds, m.startLoadingResourceTree(*appObj))
			}
			if len(cmds) > 0 {
				return m, tea.Batch(cmds...)
			}
		}
		return m, nil

	case model.ResourceSyncErrorMsg:
		// Handle resource sync error
		m.statusService.Set(fmt.Sprintf("Resource sync failed: %s", msg.Error))
		m.state.Modals.ResourceSyncError = &msg.Error
		m.state.Modals.ResourceSyncLoading = false
		// Keep modal open to show error
		return m, nil

	case model.ResourceActionsLoadedMsg:
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		st := m.state.Modals.ResourceAction
		if st == nil || m.state.Mode != model.ModeResourceAction {
			return m, nil
		}
		if st.Target != msg.Target {
			return m, nil
		}
		actions := append([]string(nil), msg.Actions...)
		sort.SliceStable(actions, func(i, j int) bool {
			return strings.ToLower(actions[i]) < strings.ToLower(actions[j])
		})
		st.Loading = false
		st.Actions = actions
		st.SelectedIdx = 0
		st.Filter = ""
		st.FilterSeq = 0
		if len(actions) == 0 {
			st.Error = "No actions available for this resource"
		}
		return m, nil

	case model.ResourceActionFilterDecayMsg:
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		st := m.state.Modals.ResourceAction
		if st == nil || m.state.Mode != model.ModeResourceAction {
			return m, nil
		}
		if st.Target != msg.Target {
			return m, nil
		}
		// Only clear if no newer keypress has occurred since this tick was scheduled.
		if msg.Seq == st.FilterSeq {
			st.Filter = ""
		}
		return m, nil

	case model.ResourceActionsErrorMsg:
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		st := m.state.Modals.ResourceAction
		if st == nil || m.state.Mode != model.ModeResourceAction {
			return m, nil
		}
		if st.Target != msg.Target {
			return m, nil
		}
		st.Loading = false
		st.Error = msg.Error
		return m, nil

	case model.ResourceActionExecutedMsg:
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		m.statusService.Set(fmt.Sprintf("Ran %s on %s/%s", msg.Action, msg.Target.Kind, msg.Target.Name))
		// Only tear down the modal if it still targets the same resource —
		// the user may have closed the original modal and opened another one
		// for a different resource before this completion arrived.
		if st := m.state.Modals.ResourceAction; st != nil && st.Target == msg.Target {
			m.state.Mode = model.ModeNormal
			m.state.Modals.ResourceAction = nil
		}
		// Trigger a refresh of the app's resource tree
		if m.state.Navigation.View == model.ViewTree && msg.AppName != "" {
			appObj := m.resolveActionRefreshApp(msg)
			return m, m.startLoadingResourceTree(*appObj)
		}
		return m, nil

	case model.ResourceActionExecuteErrorMsg:
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		st := m.state.Modals.ResourceAction
		if st == nil || m.state.Mode != model.ModeResourceAction {
			return m, nil
		}
		if st.Target != msg.Target {
			return m, nil
		}
		st.Executing = false
		st.Error = msg.Error
		return m, nil

	case model.MultiSyncCompletedMsg:
		// Gate by switch epoch
		if msg.SwitchEpoch != m.switchEpoch {
			return m, nil
		}
		// Handle multiple app sync completion
		if msg.Success {
			m.statusService.Set(fmt.Sprintf("Sync initiated for %d app(s)", msg.AppCount))
			if m.state.Modals.ConfirmSyncWatch && len(m.state.Selections.SelectedApps) > 1 {
				// Snapshot selected names before clearing
				sel := m.state.Selections.SelectedApps
				names := make([]string, 0, len(sel))
				for name, ok := range sel {
					if ok {
						names = append(names, name)
					}
				}
				if len(names) > 0 {
					var cmds []tea.Cmd
					// Clean up any existing tree watchers first
					m.cleanupTreeWatchers()
					// Reset tree view for multi-app session
					m.treeView = treeview.NewTreeView(0, 0)
					m.treeView.ApplyTheme(currentPalette)
					m.treeNav.Reset() // Reset scroll position
					m.state.SaveNavigationState()
					m.state.Navigation.View = model.ViewTree
					// Clear single-app tracker
					m.clearTreeApp()
					m.treeLoading = true
					for _, n := range names {
						var appObj *model.App
						for i := range m.state.Apps {
							if m.state.Apps[i].Name == n {
								appObj = &m.state.Apps[i]
								break
							}
						}
						if appObj == nil {
							tmp := model.App{Name: n}
							appObj = &tmp
						}
						cmds = append(cmds, m.startLoadingResourceTree(*appObj))
						cmds = append(cmds, m.startWatchingResourceTree(*appObj))
					}
					// Close modal before switching
					m.state.Modals.ConfirmTarget = nil
					m.state.Modals.ConfirmSyncLoading = false
					if m.state.Mode == model.ModeConfirmSync {
						m.state.Mode = model.ModeNormal
					}
					// Clear selections after queueing
					m.state.Selections.SelectedApps = model.NewStringSet()
					cmds = append(cmds, m.consumeTreeEvent())
					return m, tea.Batch(cmds...)
				}
			}
			// Clear selections when not opening multi tree
			m.state.Selections.SelectedApps = model.NewStringSet()
		}
		// Close confirm modal/loading state if open
		m.state.Modals.ConfirmTarget = nil
		m.state.Modals.ConfirmSyncLoading = false
		if m.state.Mode == model.ModeConfirmSync {
			m.state.Mode = model.ModeNormal
		}
		return m, nil

	case model.RefreshCompletedMsg:
		// Handle single app refresh completion
		if msg.Success {
			refreshType := "Refresh"
			if msg.Hard {
				refreshType = "Hard refresh"
			}
			m.statusService.Set(fmt.Sprintf("%s initiated for %s", refreshType, msg.AppName))

			// Set flash state based on current view
			if m.state.Navigation.View == model.ViewTree {
				m.state.UI.RefreshFlashTree = true
				if m.treeView != nil {
					m.treeView.SetFlashAll(true)
				}
			} else {
				if m.state.UI.RefreshFlashApps == nil {
					m.state.UI.RefreshFlashApps = make(map[string]bool)
				}
				m.state.UI.RefreshFlashApps[msg.AppName] = true
			}
			// Schedule flash clear after 1 second
			return m, tea.Tick(time.Second, func(t time.Time) tea.Msg {
				return model.ClearRefreshFlashMsg{}
			})
		}
		return m, nil

	case model.MultiRefreshCompletedMsg:
		// Handle multiple app refresh completion
		if msg.Success {
			refreshType := "Refresh"
			if msg.Hard {
				refreshType = "Hard refresh"
			}
			m.statusService.Set(fmt.Sprintf("%s initiated for %d app(s)", refreshType, msg.AppCount))

			// Set flash for all selected apps
			if m.state.UI.RefreshFlashApps == nil {
				m.state.UI.RefreshFlashApps = make(map[string]bool)
			}
			for appName, selected := range m.state.Selections.SelectedApps {
				if selected {
					m.state.UI.RefreshFlashApps[appName] = true
				}
			}
			// Schedule flash clear after 1 second
			return m, tea.Tick(time.Second, func(t time.Time) tea.Msg {
				return model.ClearRefreshFlashMsg{}
			})
		}
		return m, nil

	case model.ClearRefreshFlashMsg:
		// Clear the refresh flash highlight
		m.state.UI.RefreshFlashApps = nil
		m.state.UI.RefreshFlashTree = false
		if m.treeView != nil {
			m.treeView.SetFlashAll(false)
		}
		return m, nil

	case model.MultiDeleteCompletedMsg:
		// Handle multiple app delete completion
		if msg.Success {
			m.statusService.Set(fmt.Sprintf("Successfully deleted %d app(s)", msg.AppCount))
			// Clear selections after successful multi-delete
			m.state.Selections.SelectedApps = model.NewStringSet()
		}
		// Close confirm delete modal/loading state if open
		m.state.Modals.DeleteAppName = nil
		m.state.Modals.DeleteAppNamespace = nil
		m.state.Modals.DeleteConfirmationKey = ""
		m.state.Modals.DeleteError = nil
		m.state.Modals.DeleteLoading = false
		if m.state.Mode == model.ModeConfirmAppDelete {
			m.state.Mode = model.ModeNormal
		}
		// Keep selection at the same index position
		// Only adjust if selection is now beyond the list bounds
		visibleItems := m.getVisibleItemsForCurrentView()
		if m.state.Navigation.SelectedIdx >= len(visibleItems) && len(visibleItems) > 0 {
			m.state.Navigation.SelectedIdx = len(visibleItems) - 1
		}
		return m, nil

	// Rollback Messages
	case model.RollbackHistoryLoadedMsg:
		// Initialize rollback state with deployment history
		m.state.Rollback = &model.RollbackState{
			AppName:         msg.AppName,
			AppNamespace:    msg.AppNamespace,
			Rows:            msg.Rows,
			CurrentRevision: msg.CurrentRevision,
			SelectedIdx:     0,
			Loading:         false,
			Mode:            "list",
			Prune:           false,
			Watch:           true,
			DryRun:          false,
		}

		// Start loading metadata for the first visible chunk (up to 10)
		var cmds []tea.Cmd
		preload := min(10, len(msg.Rows))
		for i := 0; i < preload; i++ {
			cmds = append(cmds, m.loadRevisionMetadata(msg.AppName, i, msg.Rows[i].Revision, msg.AppNamespace))
		}

		return m, tea.Batch(cmds...)

	case model.RollbackMetadataLoadedMsg:
		// Update rollback row with loaded metadata
		if m.state.Rollback != nil && msg.RowIndex < len(m.state.Rollback.Rows) {
			row := &m.state.Rollback.Rows[msg.RowIndex]
			row.Author = &msg.Metadata.Author
			row.Date = &msg.Metadata.Date
			row.Message = &msg.Metadata.Message
		}
		return m, nil

	case model.RollbackMetadataErrorMsg:
		// Handle metadata loading error
		if m.state.Rollback != nil && msg.RowIndex < len(m.state.Rollback.Rows) {
			row := &m.state.Rollback.Rows[msg.RowIndex]
			row.MetaError = &msg.Error
		}
		return m, nil

	case model.RollbackExecutedMsg:
		// Handle rollback completion
		if msg.Success {
			m.statusService.Set(fmt.Sprintf("Rollback initiated for %s", msg.AppName))

			// Clear rollback state and return to normal mode
			m.state.Rollback = nil
			m.state.Modals.RollbackAppName = nil
			m.state.Mode = model.ModeNormal

			// Start watching tree if requested
			if msg.Watch {
				// Clean up any existing tree watchers before starting new one
				m.cleanupTreeWatchers()
				// Reset tree view for fresh single-app session
				m.treeView = treeview.NewTreeView(0, 0)
				m.treeView.ApplyTheme(currentPalette)
				m.treeView.SetSize(m.contentInnerWidth(), m.state.Terminal.Rows)
				m.treeNav.Reset() // Reset scroll position
				// Use namespace from message to avoid ambiguity when multiple apps share a name
				ns := ""
				if msg.AppNamespace != nil {
					ns = *msg.AppNamespace
				}
				appObj := model.App{Name: msg.AppName, AppNamespace: msg.AppNamespace}
				if found := m.findAppByNameAndNamespace(msg.AppName, ns); found != nil {
					appObj = *found
				}
				m.state.Navigation.View = model.ViewTree
				m.setTreeApp(appObj)
				return m, tea.Batch(m.startLoadingResourceTree(appObj), m.startWatchingResourceTree(appObj), m.consumeTreeEvent())
			}
		} else {
			m.statusService.Error(fmt.Sprintf("Rollback failed for %s", msg.AppName))
		}
		return m, nil

	case model.RollbackNavigationMsg:
		// Handle rollback navigation
		if m.state.Rollback != nil {
			switch msg.Direction {
			case "up":
				if m.state.Rollback.SelectedIdx > 0 {
					m.state.Rollback.SelectedIdx--
					// Load metadata for newly selected row if not loaded
					row := m.state.Rollback.Rows[m.state.Rollback.SelectedIdx]
					if row.Author == nil && row.MetaError == nil {
						return m, m.loadRevisionMetadata(m.state.Rollback.AppName, m.state.Rollback.SelectedIdx, row.Revision, m.state.Rollback.AppNamespace)
					}
				}
			case "down":
				if m.state.Rollback.SelectedIdx < len(m.state.Rollback.Rows)-1 {
					m.state.Rollback.SelectedIdx++
					// Load metadata for newly selected row if not loaded
					row := m.state.Rollback.Rows[m.state.Rollback.SelectedIdx]
					var cmds []tea.Cmd
					if row.Author == nil && row.MetaError == nil {
						cmds = append(cmds, m.loadRevisionMetadata(m.state.Rollback.AppName, m.state.Rollback.SelectedIdx, row.Revision, m.state.Rollback.AppNamespace))
					}
					// Opportunistically preload the next two rows' metadata to reduce "loading" gaps
					for j := 1; j <= 2; j++ {
						idx := m.state.Rollback.SelectedIdx + j
						if idx < len(m.state.Rollback.Rows) {
							r := m.state.Rollback.Rows[idx]
							if r.Author == nil && r.MetaError == nil {
								cmds = append(cmds, m.loadRevisionMetadata(m.state.Rollback.AppName, idx, r.Revision, m.state.Rollback.AppNamespace))
							}
						}
					}
					return m, tea.Batch(cmds...)
				}
			case "top":
				m.state.Rollback.SelectedIdx = 0
			case "bottom":
				m.state.Rollback.SelectedIdx = len(m.state.Rollback.Rows) - 1
			}
		}
		return m, nil

	case model.RollbackToggleOptionMsg:
		// Handle rollback option toggling
		if m.state.Rollback != nil {
			switch msg.Option {
			case "prune":
				m.state.Rollback.Prune = !m.state.Rollback.Prune
			case "watch":
				m.state.Rollback.Watch = !m.state.Rollback.Watch
			case "dryrun":
				m.state.Rollback.DryRun = !m.state.Rollback.DryRun
			}
		}
		return m, nil

	case model.RollbackConfirmMsg:
		// Handle rollback confirmation
		if m.state.Rollback != nil && m.state.Rollback.SelectedIdx < len(m.state.Rollback.Rows) {
			// Switch to confirmation mode
			m.state.Rollback.Mode = "confirm"
		}
		return m, nil

	case model.RollbackCancelMsg:
		// Handle rollback cancellation
		m.state.Rollback = nil
		m.state.Modals.RollbackAppName = nil
		m.state.Mode = model.ModeNormal
		return m, nil

	case model.RollbackShowDiffMsg:
		// Handle rollback diff request
		if m.state.Rollback != nil {
			return m, m.startRollbackDiffSession(m.state.Rollback.AppName, m.state.Rollback.AppNamespace, msg.Revision)
		}
		return m, nil

	case model.AuthValidationResultMsg:
		// Gate by switch epoch — discard auth results from a previous context
		if msg.SwitchEpoch != m.switchEpoch {
			cblog.With("component", "model").Debug("AuthValidationResultMsg: ignoring stale epoch",
				"msg_epoch", msg.SwitchEpoch, "current_epoch", m.switchEpoch)
			return m, nil
		}
		return m, func() tea.Msg { return model.SetModeMsg{Mode: msg.Mode} }

	case model.ContextSwitchResultMsg:
		return m.handleContextSwitchResult(msg)

	case model.QuitMsg:
		return m, tea.Quit

	case model.SetInitialLoadingMsg:
		cblog.With("component", "model").Info("SetInitialLoadingMsg received", "loading", msg.Loading)
		// Control the initial loading modal display
		m.state.Modals.InitialLoading = msg.Loading
		// Don't trigger load here - let SetModeMsg handle it to avoid duplicates

		return m, nil

	// Update Messages
	case model.UpdateCheckCompletedMsg:
		if msg.Error != nil {
			cblog.With("component", "update").Error("Update check failed", "err", msg.Error)
			return m, nil
		}
		if msg.UpdateInfo != nil {
			// Check if this is a new update notification (different version or first time)
			isNewNotification := m.state.UI.UpdateInfo == nil ||
				!m.state.UI.UpdateInfo.Available ||
				m.state.UI.UpdateInfo.LatestVersion != msg.UpdateInfo.LatestVersion

			m.state.UI.UpdateInfo = msg.UpdateInfo
			m.state.UI.IsVersionOutdated = msg.UpdateInfo.Available

			if msg.UpdateInfo.Available {
				// Set notification timestamp for new notifications
				if isNewNotification && msg.UpdateInfo.NotificationShownAt == nil {
					now := time.Now()
					msg.UpdateInfo.NotificationShownAt = &now
					m.state.UI.UpdateInfo = msg.UpdateInfo
				}

				m.state.UI.LatestVersion = &msg.UpdateInfo.LatestVersion
				cblog.With("component", "update").Info("Update available",
					"current", msg.UpdateInfo.CurrentVersion,
					"latest", msg.UpdateInfo.LatestVersion,
					"install_method", msg.UpdateInfo.InstallMethod)
			}
		}
		return m, nil

	case model.UpgradeRequestedMsg:
		return m, m.handleUpgradeRequest()

	case model.UpgradeProgressMsg:
		m.statusService.Set(msg.Message)
		return m, nil

	case model.UpgradeCompletedMsg:
		if msg.Success {
			// Show upgrade success modal
			m.state.Mode = model.ModeUpgradeSuccess
			m.state.Modals.UpgradeLoading = false
		} else {
			// Show upgrade error modal with detailed instructions
			errorMsg := msg.Error.Error()
			m.state.Modals.UpgradeError = &errorMsg
			m.state.Mode = model.ModeUpgradeError
			m.state.Modals.UpgradeLoading = false
		}
		return m, nil

	case model.ChangelogLoadedMsg:
		m.state.Modals.ChangelogLoading = false
		if msg.Error != nil {
			return m, func() tea.Msg {
				return model.StatusChangeMsg{
					Status: "Could not fetch changelog: " + msg.Error.Error(),
				}
			}
		}
		// Format and display in pager
		formatted := FormatChangelog(msg.Content)
		return m, m.openTextPager("Changelog", formatted)
	}

	return m, nil
}

func (m *Model) applyBatchAppUpdate(upd model.AppUpdatedMsg) {
	found := false
	if idx := m.state.Index; idx != nil {
		if i, ok := idx.NameToIndex[upd.App.Name]; ok && i < len(m.state.Apps) && m.state.Apps[i].Name == upd.App.Name {
			m.state.Apps[i] = upd.App
			found = true
		}
	}
	if !found {
		// Fallback to linear scan (index may be stale during in-batch mutations)
		for i, a := range m.state.Apps {
			if a.Name == upd.App.Name {
				m.state.Apps[i] = upd.App
				found = true
				break
			}
		}
	}
	if !found {
		m.state.Apps = append(m.state.Apps, upd.App)
	}
	// Update tree view sync statuses
	if m.treeView != nil && m.state.Navigation.View == model.ViewTree && len(upd.ResourcesJSON) > 0 {
		var resources []api.ResourceStatus
		if json.Unmarshal(upd.ResourcesJSON, &resources) == nil {
			m.treeView.SetResourceStatuses(upd.App.Name, resources)
		}
	}
}

// setTreeApp atomically stores all relevant info about the app being shown in
// the tree view. Always use this instead of assigning UI.TreeApp fields directly,
// so that adding new fields only requires updating this one function.
func (m *Model) setTreeApp(app model.App) {
	m.state.UI.TreeApp = &model.TreeAppInfo{
		Name:          app.Name,
		AppNamespace:  app.AppNamespace,
		DestNamespace: app.Namespace,
		Project:       app.Project,
	}
}

// clearTreeApp clears all tree-app state. Always use this instead of nil-ing
// individual fields.
func (m *Model) clearTreeApp() {
	m.state.UI.TreeApp = nil
}

// resolveActionRefreshApp finds the App entry in m.state.Apps that the post-
// execute tree refresh should reload. It matches on (Name, AppNamespace) so
// that two apps sharing a name in different ArgoCD namespaces resolve to the
// correct one. Falls back to a synthesized App carrying both fields when no
// match is found.
func (m *Model) resolveActionRefreshApp(msg model.ResourceActionExecutedMsg) *model.App {
	wantNs := ""
	if msg.Target.AppNamespace != nil {
		wantNs = *msg.Target.AppNamespace
	}
	for i := range m.state.Apps {
		if m.state.Apps[i].Name != msg.AppName {
			continue
		}
		appNs := ""
		if m.state.Apps[i].AppNamespace != nil {
			appNs = *m.state.Apps[i].AppNamespace
		}
		if appNs == wantNs {
			return &m.state.Apps[i]
		}
	}
	return &model.App{Name: msg.AppName, AppNamespace: msg.Target.AppNamespace}
}

func (m *Model) applyBatchAppDelete(name string) bool {
	if name == "" {
		return false
	}
	if idx := m.state.Index; idx != nil {
		if i, ok := idx.NameToIndex[name]; ok && i < len(m.state.Apps) && m.state.Apps[i].Name == name {
			m.state.Apps = append(m.state.Apps[:i], m.state.Apps[i+1:]...)
			return true
		}
	}
	for i, a := range m.state.Apps {
		if a.Name == name {
			m.state.Apps = append(m.state.Apps[:i], m.state.Apps[i+1:]...)
			return true
		}
	}
	return false
}
