package main

import (
	"archive/tar"
	"archive/zip"
	"compress/gzip"
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"log"
	"net/mail"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"slices"
	"sort"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"
	calendar "github.com/floatpane/go-icalendar"
	"github.com/floatpane/matcha/backend"
	_ "github.com/floatpane/matcha/backend/imap"
	_ "github.com/floatpane/matcha/backend/jmap"
	_ "github.com/floatpane/matcha/backend/maildir"
	_ "github.com/floatpane/matcha/backend/pop3"
	matchaCli "github.com/floatpane/matcha/cli"
	"github.com/floatpane/matcha/clib"
	"github.com/floatpane/matcha/clib/macos"
	"github.com/floatpane/matcha/config"
	matchaDaemon "github.com/floatpane/matcha/daemon"
	"github.com/floatpane/matcha/daemonclient"
	"github.com/floatpane/matcha/daemonrpc"
	"github.com/floatpane/matcha/fetcher"
	"github.com/floatpane/matcha/i18n"
	_ "github.com/floatpane/matcha/i18n/languages"
	"github.com/floatpane/matcha/internal/httpclient"
	"github.com/floatpane/matcha/internal/logging"
	"github.com/floatpane/matcha/internal/loglevel"
	"github.com/floatpane/matcha/notify"
	"github.com/floatpane/matcha/plugin"
	"github.com/floatpane/matcha/sender"
	"github.com/floatpane/matcha/theme"
	"github.com/floatpane/matcha/tui"
	"github.com/floatpane/termimage"
	"github.com/google/uuid"
	lua "github.com/yuin/gopher-lua"
)

const (
	initialEmailLimit = 50
	paginationLimit   = 50
	maxCacheEmails    = 100
)

// Version variables are injected by the build (GoReleaser ldflags).
// They default to "dev" when not set by the build system.
var (
	version = "dev"
	commit  = ""
	date    = ""

	// httpClient is used for all outbound HTTP requests (update checks, asset downloads).
	httpClient = httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5)
)

const (
	goosDarwin  = "darwin"
	folderInbox = "INBOX"
)

// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
type UpdateAvailableMsg struct {
	Latest  string
	Current string
}

// internal struct for parsing GitHub release JSON.
type githubRelease struct {
	TagName string `json:"tag_name"`
	Assets  []struct {
		Name               string `json:"name"`
		BrowserDownloadURL string `json:"browser_download_url"`
	} `json:"assets"`
}

type mainModel struct {
	current       tea.Model
	previousModel tea.Model
	config        *config.Config
	plugins       *plugin.Manager
	// Folder-based email storage
	folderEmails map[string][]fetcher.Email // key: folderName
	folderInbox  *tui.FolderInbox
	// Legacy fields kept for email actions
	emails       []fetcher.Email
	emailsByAcct map[string][]fetcher.Email
	width        int
	height       int
	// IMAP IDLE
	idleWatcher *fetcher.IdleWatcher
	idleUpdates chan fetcher.IdleUpdate
	// Multi-protocol backend providers (keyed by account ID)
	providers   map[string]backend.Provider
	providersMu sync.RWMutex
	// Daemon client service (daemon or direct fallback)
	service daemonclient.Service
	// Plugin prompt waiting for user input
	pendingPrompt *plugin.PendingPrompt
	// mailto: URL parsed from os.Args
	mailtoURL *url.URL
	// Optional in-app log panel.
	showLogPanel bool
	logCh        <-chan logging.Entry
	logPanel     *tui.LogPanel
}

type logEntryMsg struct {
	entry logging.Entry
}

func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel {
	idleUpdates := make(chan fetcher.IdleUpdate, 16)
	initialModel := &mainModel{
		emailsByAcct: make(map[string][]fetcher.Email),
		folderEmails: make(map[string][]fetcher.Email),
		idleUpdates:  idleUpdates,
		idleWatcher:  fetcher.NewIdleWatcher(idleUpdates),
		providers:    make(map[string]backend.Provider),
		mailtoURL:    mailtoURL,
	}

	if cfg == nil || !cfg.HasAccounts() {
		hideTips := false
		if cfg != nil {
			hideTips = cfg.HideTips
		}
		initialModel.current = tui.NewLogin(hideTips)
	} else {
		if mailtoURL != nil {
			// mailto:addr@example.com?subject=test
			to := mailtoURL.Opaque
			if to == "" {
				to = mailtoURL.Path
			}
			if to == "" {
				to = mailtoURL.Query().Get("to")
			}
			subject := mailtoURL.Query().Get("subject")
			body := mailtoURL.Query().Get("body")
			composer := tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
			composer.SetSpellcheckOptions(cfg.DisableSpellcheck, cfg.DisableSpellSuggestions)
			initialModel.current = composer
		} else {
			initialModel.current = tui.NewChoice()
		}
		initialModel.config = cfg
	}
	return initialModel
}

// ensureProviders creates backend providers for all configured accounts.
// newSettings constructs a settings model and wires it to the plugin manager
// so the Plugins category can list and edit plugin-declared settings.
func (m *mainModel) newSettings() *tui.Settings {
	s := tui.NewSettings(m.config)
	if m.plugins != nil {
		s.SetPlugins(m.plugins)
	}
	return s
}

// applySpellcheckOptions propagates the current Config's spellcheck
// preferences onto a freshly-constructed Composer.
func (m *mainModel) applySpellcheckOptions(c *tui.Composer) {
	if c == nil || m.config == nil {
		return
	}
	c.SetSpellcheckOptions(m.config.DisableSpellcheck, m.config.DisableSpellSuggestions)
}

func (m *mainModel) ensureProviders() {
	if m.config == nil {
		return
	}
	for _, acct := range m.config.Accounts {
		m.providersMu.RLock()
		_, ok := m.providers[acct.ID]
		m.providersMu.RUnlock()

		if ok {
			continue
		}

		p, err := backend.New(&acct)
		if err != nil {
			log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
			continue
		}

		m.providersMu.Lock()
		m.providers[acct.ID] = p
		m.providersMu.Unlock()
	}
}

// getProvider returns the backend provider for the given account.
func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
	if acct == nil {
		return nil
	}

	m.providersMu.RLock()
	p := m.providers[acct.ID]
	m.providersMu.RUnlock()

	return p
}

func (m *mainModel) Init() tea.Cmd {
	cmds := []tea.Cmd{m.current.Init(), checkForUpdatesCmd()}
	if m.showLogPanel && m.logCh != nil {
		cmds = append(cmds, waitForLogEntry(m.logCh))
	}
	return tea.Batch(cmds...)
}

func waitForLogEntry(ch <-chan logging.Entry) tea.Cmd {
	return func() tea.Msg {
		entry := <-ch
		return logEntryMsg{entry: entry}
	}
}

func unreadBadgeCount(emailsByAcct, folderEmails map[string][]fetcher.Email) int {
	count := 0
	seen := make(map[string]struct{})

	countUnread := func(e fetcher.Email) {
		if e.IsRead {
			return
		}
		key := fmt.Sprintf("%s:%d", e.AccountID, e.UID)
		if _, ok := seen[key]; ok {
			return
		}
		seen[key] = struct{}{}
		count++
	}

	// Count unread across all accounts (cached/loaded emails)
	for _, emails := range emailsByAcct {
		for _, e := range emails {
			countUnread(e)
		}
	}
	// Also check folderEmails for unread status
	for _, emails := range folderEmails {
		for _, e := range emails {
			countUnread(e)
		}
	}
	return count
}

func (m *mainModel) syncUnreadBadge() {
	if runtime.GOOS != goosDarwin && loglevel.Get() < loglevel.LevelDebug {
		return
	}
	count := unreadBadgeCount(m.emailsByAcct, m.folderEmails)
	loglevel.Debugf("unread badge count: %d", count)
	if runtime.GOOS != goosDarwin {
		return
	}
	_ = macos.SetBadge(count)
}

func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
	var cmd tea.Cmd
	var cmds []tea.Cmd
	searchWasActive := false
	filterWasActive := false
	splitWasOpen := false

	if msg, ok := msg.(logEntryMsg); ok {
		_ = msg.entry
		return m, waitForLogEntry(m.logCh)
	}

	if msg, ok := msg.(tea.WindowSizeMsg); ok {
		m.width = msg.Width
		m.height = msg.Height
		m.current, cmd = m.current.Update(m.currentWindowSize())
		return m, cmd
	}

	if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == config.Keybinds.Global.Cancel {
		switch current := m.current.(type) {
		case *tui.Inbox:
			searchWasActive = current.IsSearchActive()
			filterWasActive = current.IsFilterActive()
		case *tui.FolderInbox:
			if inbox := current.GetInbox(); inbox != nil {
				searchWasActive = inbox.IsSearchActive()
				filterWasActive = inbox.IsFilterActive()
			}
			splitWasOpen = current.HasSplitPreview()
		}
	}

	m.current, cmd = m.current.Update(msg)
	cmds = append(cmds, cmd)

	// Fire composer_updated hook on key presses when the composer is active
	if keyMsg, isKey := msg.(tea.KeyPressMsg); isKey {
		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
			m.syncPluginStatus()
			m.applyPluginFields(composer)
		}

		// Check plugin key bindings for the current view
		if m.plugins != nil {
			if bindingCmd := m.handlePluginKeyBinding(keyMsg); bindingCmd != nil {
				cmds = append(cmds, bindingCmd)
			}
		}
	}

	switch msg := msg.(type) {
	case tea.KeyPressMsg:
		if msg.String() == "ctrl+c" {
			// Persist an in-progress draft so quitting the composer
			// doesn't discard the user's work.
			if composer, ok := m.current.(*tui.Composer); ok && composer.HasContent() {
				if err := config.SaveDraft(composer.ToDraft()); err != nil {
					log.Printf("Error saving draft on quit: %v", err)
				}
			}
			m.idleWatcher.StopAll()
			if m.service != nil {
				m.service.Close() //nolint:errcheck,gosec
			}
			return m, tea.Quit
		}
		if msg.String() == "esc" {
			switch m.current.(type) {
			case *tui.FilePicker:
				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
			case *tui.FolderInbox, *tui.Inbox, *tui.Login:
				if searchWasActive || filterWasActive || splitWasOpen {
					return m, tea.Batch(cmds...)
				}
				m.idleWatcher.StopAll()
				m.current = tui.NewChoice()
				m.current, _ = m.current.Update(m.currentWindowSize())
				return m, m.current.Init()
			}
		}

	case tui.BackToInboxMsg:
		if m.folderInbox != nil {
			m.current = m.folderInbox
		} else {
			m.current = tui.NewChoice()
			m.current, _ = m.current.Update(m.currentWindowSize())
		}
		return m, nil

	case tui.BackToMailboxMsg:
		// Ensure kitty graphics are cleared when leaving email view
		tui.ClearKittyGraphics()
		if m.folderInbox != nil {
			m.current = m.folderInbox
			return m, nil
		}
		m.current = tui.NewChoice()
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, nil

	case tui.DiscardDraftMsg:
		// Save draft to disk
		if msg.ComposerState != nil {
			draft := msg.ComposerState.ToDraft()

			if err := config.SaveDraft(draft); err != nil {
				log.Printf("Error saving draft: %v", err)
			}
		}
		m.current = tui.NewChoice()
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.OAuth2CompleteMsg:
		if msg.Err != nil {
			log.Printf("OAuth2 authorization failed: %v", msg.Err)
		}
		// After OAuth2 flow, go to the choice menu so user can proceed
		m.current = tui.NewChoice()
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.Credentials:
		// Split FetchEmail by commas to support multiple fetch addresses.
		// Each address creates a separate account sharing the same login credentials.
		fetchEmails := []string{""}
		if msg.FetchEmail != "" {
			fetchEmails = fetchEmails[:0]
			for _, fe := range strings.Split(msg.FetchEmail, ",") {
				if trimmed := strings.TrimSpace(fe); trimmed != "" {
					fetchEmails = append(fetchEmails, trimmed)
				}
			}
			if len(fetchEmails) == 0 {
				fetchEmails = []string{""}
			}
		}

		if m.config == nil {
			m.config = &config.Config{}
		}

		// Check if we're editing an existing account
		isEdit := false
		var lastAccount config.Account
		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
			isEdit = true
			existingID := login.GetAccountID()

			account := config.Account{
				ID:              existingID,
				Name:            msg.Name,
				Email:           msg.Host,
				Password:        msg.Password,
				ServiceProvider: msg.Provider,
				FetchEmail:      fetchEmails[0],
				SendAsEmail:     msg.SendAsEmail,
				CatchAll:        msg.CatchAll,
				AuthMethod:      msg.AuthMethod,
				Protocol:        msg.Protocol,
				Insecure:        msg.Insecure,
				JMAPEndpoint:    msg.JMAPEndpoint,
				POP3Server:      msg.POP3Server,
				POP3Port:        msg.POP3Port,
				MaildirPath:     msg.MaildirPath,
				SC:              &config.SessionCache{},
			}

			if msg.Provider == "custom" || msg.Protocol == "pop3" {
				account.IMAPServer = msg.IMAPServer
				account.IMAPPort = msg.IMAPPort
				account.SMTPServer = msg.SMTPServer
				account.SMTPPort = msg.SMTPPort
			}

			if account.FetchEmail == "" && account.Email != "" {
				account.FetchEmail = account.Email
			}

			// Find and update the existing account, preserving S/MIME settings
			for i, acc := range m.config.Accounts {
				if acc.ID == existingID {
					account.SMIMECert = acc.SMIMECert
					account.SMIMEKey = acc.SMIMEKey
					account.SMIMESignByDefault = acc.SMIMESignByDefault
					if account.Password == "" {
						account.Password = acc.Password
					}
					m.config.Accounts[i] = account
					break
				}
			}
			lastAccount = account
		} else {
			// New account: create one account per fetch email address
			for _, fe := range fetchEmails {
				account := config.Account{
					ID:              uuid.New().String(),
					Name:            msg.Name,
					Email:           msg.Host,
					Password:        msg.Password,
					ServiceProvider: msg.Provider,
					FetchEmail:      fe,
					SendAsEmail:     msg.SendAsEmail,
					CatchAll:        msg.CatchAll,
					AuthMethod:      msg.AuthMethod,
					Protocol:        msg.Protocol,
					JMAPEndpoint:    msg.JMAPEndpoint,
					POP3Server:      msg.POP3Server,
					POP3Port:        msg.POP3Port,
					MaildirPath:     msg.MaildirPath,
					SC:              &config.SessionCache{},
				}

				if msg.Provider == "custom" || msg.Protocol == "pop3" {
					account.IMAPServer = msg.IMAPServer
					account.IMAPPort = msg.IMAPPort
					account.SMTPServer = msg.SMTPServer
					account.SMTPPort = msg.SMTPPort
				}

				if account.FetchEmail == "" && account.Email != "" {
					account.FetchEmail = account.Email
				}

				m.config.AddAccount(account)
				lastAccount = account
			}
		}

		if err := config.SaveConfig(m.config); err != nil {
			log.Printf("could not save config: %v", err)
			return m, tea.Quit
		}

		// If OAuth2, launch the authorization flow after saving the account
		if lastAccount.IsOAuth2() {
			email := lastAccount.Email
			provider := lastAccount.ServiceProvider
			return m, func() tea.Msg {
				err := config.RunOAuth2Flow(email, provider, "", "")
				return tui.OAuth2CompleteMsg{Email: email, Err: err}
			}
		}

		if isEdit {
			m.current = m.newSettings()
		} else {
			m.current = tui.NewChoice()
		}
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.GoToInboxMsg:
		if m.config == nil || !m.config.HasAccounts() {
			hideTips := false
			if m.config != nil {
				hideTips = m.config.HideTips
			}
			m.current = tui.NewLogin(hideTips)
			return m, m.current.Init()
		}
		m.ensureProviders()
		// Load cached folders from all accounts, merge unique names
		seen := make(map[string]bool)
		var cachedFolders []string
		unread := make(map[string]int)
		for _, acc := range m.config.Accounts {
			folders, counters := config.GetCachedFolders(acc.ID)
			for _, f := range folders {
				if !seen[f] {
					seen[f] = true
					cachedFolders = append(cachedFolders, f)
				}
				if count, ok := counters[f]; ok {
					unread[f] += count
				}
			}
		}
		// Always ensure INBOX is present, even if cache is empty or stale
		if !seen[folderInbox] {
			cachedFolders = append([]string{folderInbox}, cachedFolders...)
		}
		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
		m.folderInbox.SetUnreadCounts(unread)
		m.folderInbox.SetDateFormat(m.config.GetDateFormat())
		m.folderInbox.SetDetailedDates(m.config.EnableDetailedDates)
		m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
		m.folderInbox.SetDisableImages(m.config.DisableImages)
		// Use cached INBOX emails for instant display (memory first, then disk)
		if cached, ok := m.folderEmails[folderInbox]; ok && len(cached) > 0 {
			m.folderInbox.SetEmails(cached, m.config.Accounts)
		} else if diskCached := loadFolderEmailsFromCache(folderInbox); len(diskCached) > 0 {
			m.folderEmails[folderInbox] = diskCached
			m.emails = diskCached
			m.emailsByAcct = make(map[string][]fetcher.Email)
			for _, email := range diskCached {
				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
			}
			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
		}
		m.current = m.folderInbox
		m.current, _ = m.current.Update(m.currentWindowSize())
		// Initialize daemon service if not already set.
		if m.service == nil {
			m.service = daemonclient.NewService(m.config)
		}
		if m.service.IsDaemon() {
			// Subscribe to INBOX updates if using daemon.
			for _, acct := range m.config.Accounts {
				m.service.Subscribe(acct.ID, folderInbox) //nolint:errcheck,gosec
			}
		} else {
			// Start IDLE watchers for all accounts on INBOX
			for i := range m.config.Accounts {
				m.idleWatcher.Watch(&m.config.Accounts[i], folderInbox)
			}
		}
		// Fetch folders and INBOX emails in parallel (background refresh)
		batchCmds := []tea.Cmd{
			m.current.Init(),
			fetchFoldersCmd(m.config),
			fetchFolderEmailsCmd(m.config, folderInbox),
			listenForIdleUpdates(m.idleUpdates),
		}
		if m.service.IsDaemon() {
			batchCmds = append(batchCmds, listenForDaemonEvents(m.service.Events()))
		}
		return m, tea.Batch(batchCmds...)

	case tui.FoldersFetchedMsg:
		if m.folderInbox == nil {
			return m, nil
		}
		var folderNames []string
		unread := make(map[string]int)
		for _, f := range msg.MergedFolders {
			folderNames = append(folderNames, f.Name)
			if f.Unread > 0 {
				unread[f.Name] = int(f.Unread)
			}
		}
		m.folderInbox.SetFolders(folderNames)
		m.folderInbox.SetUnreadCounts(unread)
		// Cache folder lists per account
		for accID, folders := range msg.FoldersByAccount {
			var names []string
			unread := make(map[string]int)
			for _, f := range folders {
				names = append(names, f.Name)
				if f.Unread > 0 {
					unread[f.Name] = int(f.Unread)
				}
			}
			go config.SaveAccountFolders(accID, names, unread) //nolint:errcheck
		}
		// Per-account fetch errors (e.g. broken IMAP login, unreachable
		// server) are non-fatal: other accounts' folders are still shown.
		// Surface them as a transient overlay so the user knows why an
		// account's folders are missing instead of silently dropping them.
		// Reuses the PluginNotifyMsg pattern (save current view, show
		// status with a tea.Tick that fires RestoreViewMsg).
		if len(msg.Errors) > 0 {
			lookup := map[string]string{}
			if m.config != nil {
				for _, acc := range m.config.Accounts {
					name := acc.Email
					if name == "" {
						name = acc.Name
					}
					if name == "" {
						name = acc.ID
					}
					lookup[acc.ID] = name
				}
			}
			parts := make([]string, 0, len(msg.Errors))
			for accID, err := range msg.Errors {
				name := lookup[accID]
				if name == "" {
					name = accID
				}
				parts = append(parts, fmt.Sprintf("%s: %v", name, err))
			}
			sort.Strings(parts)
			m.previousModel = m.current
			m.current = tui.NewStatus(fmt.Sprintf(
				"Folder fetch failed for %d account(s): %s",
				len(parts), strings.Join(parts, "; "),
			))
			return m, tea.Tick(4*time.Second, func(t time.Time) tea.Msg {
				return tui.RestoreViewMsg{}
			})
		}
		return m, nil

	case tui.SwitchFolderMsg:
		if m.config == nil {
			return m, nil
		}
		// Update IDLE watchers to monitor the new folder
		for i := range m.config.Accounts {
			// Only start IDLE for accounts that actually have this folder
			folders, _ := config.GetCachedFolders(m.config.Accounts[i].ID)
			if !slices.Contains(folders, msg.FolderName) {
				if m.service != nil && m.service.IsDaemon() {
					m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder) //nolint:errcheck,gosec
				} else {
					m.idleWatcher.Stop(m.config.Accounts[i].ID)
				}
				continue
			}
			if m.service != nil && m.service.IsDaemon() {
				// Unsubscribe from old, subscribe to new.
				if msg.PreviousFolder != "" {
					m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder) //nolint:errcheck,gosec
				}
				m.service.Subscribe(m.config.Accounts[i].ID, msg.FolderName) //nolint:errcheck,gosec
			} else {
				m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
			}
		}
		if m.plugins != nil {
			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
			m.syncPluginStatus()
			m.syncPluginKeyBindings()
		}
		// Use in-memory cache if available
		if cached, ok := m.folderEmails[msg.FolderName]; ok {
			m.emails = cached
			m.emailsByAcct = make(map[string][]fetcher.Email)
			for _, email := range cached {
				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
			}
			if m.folderInbox != nil {
				m.folderInbox.SetEmails(cached, m.config.Accounts)
				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
				m.folderInbox.SetLoadingEmails(false)
			}
			return m, m.pluginNotifyCmd()
		}
		// Fall back to disk cache for instant display, then fetch fresh in background
		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
			m.folderEmails[msg.FolderName] = diskCached
			m.emails = diskCached
			m.emailsByAcct = make(map[string][]fetcher.Email)
			for _, email := range diskCached {
				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
			}
			if m.folderInbox != nil {
				m.folderInbox.SetEmails(diskCached, m.config.Accounts)
				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
				m.folderInbox.SetLoadingEmails(false)
			}
			// Still fetch fresh emails in background
			return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
		}
		if m.folderInbox != nil {
			m.folderInbox.SetLoadingEmails(true)
		}
		return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())

	case tui.PluginNotifyMsg:
		m.previousModel = m.current
		m.current = tui.NewStatus(msg.Message)
		dur := time.Duration(msg.Duration * float64(time.Second))
		if dur <= 0 {
			dur = 2 * time.Second
		}
		return m, tea.Tick(dur, func(t time.Time) tea.Msg {
			return tui.RestoreViewMsg{}
		})

	case tui.PluginPromptSubmitMsg:
		if m.pendingPrompt != nil {
			if composer, ok := m.current.(*tui.Composer); ok {
				composer.HidePluginPrompt()
				m.plugins.ResolvePrompt(m.pendingPrompt, msg.Value)
				m.applyPluginFields(composer)
				m.syncPluginStatus()
			}
			m.pendingPrompt = nil
		}
		return m, nil

	case tui.PluginPromptCancelMsg:
		if composer, ok := m.current.(*tui.Composer); ok {
			composer.HidePluginPrompt()
		}
		m.pendingPrompt = nil
		return m, nil

	case tui.FolderEmailsFetchedMsg:
		if m.folderInbox == nil {
			return m, nil
		}
		// Call plugin hooks for received emails
		if m.plugins != nil {
			for _, email := range msg.Emails {
				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
				m.plugins.CallHook(plugin.HookEmailReceived, t)
			}
		}
		// Always cache in memory and to disk
		m.folderEmails[msg.FolderName] = msg.Emails
		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
		// Prune stale body cache entries
		go func() {
			validUIDs := make(map[uint32]string, len(msg.Emails))
			for _, e := range msg.Emails {
				validUIDs[e.UID] = e.AccountID
			}
			_ = config.PruneEmailBodyCache(msg.FolderName, validUIDs, m.config.GetBodyCacheThreshold())
		}()
		// Only update the view if the user is still on this folder
		if m.folderInbox.GetCurrentFolder() != msg.FolderName {
			return m, nil
		}
		m.emails = msg.Emails
		m.emailsByAcct = make(map[string][]fetcher.Email)
		for _, email := range msg.Emails {
			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
		}
		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
		m.folderInbox.SetLoadingEmails(false)
		m.syncPluginStatus()
		m.syncPluginKeyBindings()
		return m, tea.Batch(append(m.pluginFlagCmds(), m.pluginNotifyCmd())...)

	case tui.FetchFolderMoreEmailsMsg:
		if msg.AccountID == "" || m.config == nil {
			return m, nil
		}
		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			return m, nil
		}
		limit := uint32(paginationLimit)
		if msg.Limit > 0 {
			limit = msg.Limit
		}
		return m, tea.Batch(
			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
			fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
		)

	case tui.FolderEmailsAppendedMsg:
		// Ignore stale appends for a folder the user has moved away from
		if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
			return m, nil
		}
		m.folderInbox.Update(msg)
		// Update local stores and per-folder cache
		for _, email := range msg.Emails {
			m.emails = append(m.emails, email)
			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
		}
		m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
		go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
		return m, nil

	case tui.MoveEmailToFolderMsg:
		if m.config == nil {
			return m, nil
		}
		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			return m, nil
		}

		folderName := folderInbox
		if m.folderInbox != nil {
			folderName = m.folderInbox.GetCurrentFolder()
			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
		}

		m.removeEmailFromStores(msg.UID, msg.AccountID)

		if emails, ok := m.folderEmails[folderName]; ok {
			var filtered []fetcher.Email
			for _, e := range emails {
				if e.UID != msg.UID || e.AccountID != msg.AccountID {
					filtered = append(filtered, e)
				}
			}
			m.folderEmails[folderName] = filtered
			go saveFolderEmailsToCache(folderName, filtered)
		}

		return m, m.moveEmailToFolderCmd(msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder)

	case tui.UpdatePreviewMsg:
		// Trigger preview body fetch
		if m.folderInbox == nil {
			return m, nil
		}
		folderName := m.folderInbox.GetCurrentFolder()
		// Check cache first
		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil {
			var attachments []fetcher.Attachment
			for _, ca := range cached.Attachments {
				att := fetcher.Attachment{
					Filename:         ca.Filename,
					PartID:           ca.PartID,
					Encoding:         ca.Encoding,
					MIMEType:         ca.MIMEType,
					ContentID:        ca.ContentID,
					Inline:           ca.Inline,
					IsSMIMESignature: ca.IsSMIMESignature,
					SMIMEVerified:    ca.SMIMEVerified,
					IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
					IsCalendarInvite: ca.IsCalendarInvite,
				}
				if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
					att.Data = ca.CalendarData
				}
				attachments = append(attachments, att)
			}
			return m, func() tea.Msg {
				return tui.PreviewBodyFetchedMsg{
					UID:          msg.UID,
					Body:         cached.Body,
					BodyMIMEType: cached.BodyMIMEType,
					Attachments:  attachments,
					AccountID:    msg.AccountID,
				}
			}
		}
		return m, fetchPreviewBodyCmd(m.config, msg.UID, msg.AccountID, folderName)

	case tui.PreviewBodyFetchedMsg:
		// Cache body and forward to FolderInbox
		if msg.Err == nil && m.folderInbox != nil {
			folderName := m.folderInbox.GetCurrentFolder()
			var cachedAttachments []config.CachedAttachment
			for _, a := range msg.Attachments {
				cachedAttachments = append(cachedAttachments, config.CachedAttachment{
					Filename:  a.Filename,
					PartID:    a.PartID,
					Encoding:  a.Encoding,
					MIMEType:  a.MIMEType,
					ContentID: a.ContentID,
					Inline:    a.Inline,
				})
			}
			go func() {
				err := config.SaveEmailBody(folderName, config.CachedEmailBody{
					UID:          msg.UID,
					AccountID:    msg.AccountID,
					Body:         msg.Body,
					BodyMIMEType: msg.BodyMIMEType,
					Attachments:  cachedAttachments,
				}, m.config.GetBodyCacheThreshold())
				if err != nil {
					loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
				}
			}()
		}
		// Forward to FolderInbox for rendering
		if m.folderInbox != nil {
			m.current, cmd = m.current.Update(msg)
			return m, cmd
		}
		return m, nil

	case tui.EmailMovedMsg:
		if msg.Err != nil {
			log.Printf("Move failed: %v", msg.Err)
			if m.folderInbox != nil {
				m.previousModel = m.folderInbox
			}
			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
				return tui.RestoreViewMsg{}
			})
		}
		return m, nil

	case tui.CachedEmailsLoadedMsg:
		// Cache is no longer used for the folder-based inbox flow
		// This handler is kept for backwards compatibility but simply fetches normally
		if m.folderInbox == nil {
			return m, nil
		}
		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())

	case tui.IdleNewMailMsg:
		// Send desktop notification for new mail (if enabled)
		if m.config == nil || !m.config.DisableNotifications {
			accountName := msg.AccountID
			if m.config != nil {
				if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
					accountName = acc.Email
				}
			}
			go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName)) //nolint:errcheck
		}

		// IDLE detected new mail — refetch the folder if we're viewing it
		if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
			return m, tea.Batch(
				fetchFolderEmailsCmd(m.config, msg.FolderName),
				listenForIdleUpdates(m.idleUpdates),
			)
		}
		// Re-subscribe even if not viewing the affected folder
		return m, listenForIdleUpdates(m.idleUpdates)

	case tui.DaemonEventMsg:
		if msg.Event == nil {
			return m, nil
		}
		var cmds []tea.Cmd
		// Re-subscribe for next event.
		if m.service != nil && m.service.IsDaemon() {
			cmds = append(cmds, listenForDaemonEvents(m.service.Events()))
		}
		switch msg.Event.Type {
		case daemonrpc.EventNewMail:
			var ev daemonrpc.NewMailEvent
			if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
				if m.config == nil || !m.config.DisableNotifications {
					accountName := ev.AccountID
					if m.config != nil {
						if acc := m.config.GetAccountByID(ev.AccountID); acc != nil {
							accountName = acc.Email
						}
					}
					go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", ev.Folder, accountName)) //nolint:errcheck
				}

				if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
					cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
				}
			}
		case daemonrpc.EventSyncComplete:
			var ev daemonrpc.SyncCompleteEvent
			if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
				if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
					cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
				}
			}
		}
		return m, tea.Batch(cmds...)

	case tui.RequestRefreshMsg:
		// Folder-based refresh: clear folder cache and refetch
		if msg.FolderName != "" && m.config != nil {
			delete(m.folderEmails, msg.FolderName)
			if m.folderInbox != nil {
				m.folderInbox.SetRefreshing(true)
			}
			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
		}
		return m, tea.Batch(
			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
			refreshEmails(m.config, msg.Mailbox, msg.Counts),
		)

	case tui.EmailsRefreshedMsg:
		// Merge refreshed emails with any paginated emails already loaded.
		for accID, refreshed := range msg.EmailsByAccount {
			refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
			for _, e := range refreshed {
				refreshedUIDs[e.UID] = struct{}{}
			}
			if existing, ok := m.emailsByAcct[accID]; ok {
				for _, e := range existing {
					if _, found := refreshedUIDs[e.UID]; !found {
						refreshed = append(refreshed, e)
					}
				}
			}
			m.emailsByAcct[accID] = refreshed
		}
		m.emails = flattenAndSort(m.emailsByAcct)
		m.syncUnreadBadge()

		// Update folder inbox if it exists
		if m.folderInbox != nil {
			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
			m.folderInbox.GetInbox().Update(msg)
		}
		return m, nil

	case tui.AllEmailsFetchedMsg:
		m.emailsByAcct = msg.EmailsByAccount
		m.emails = flattenAndSort(msg.EmailsByAccount)
		m.syncUnreadBadge()

		if m.folderInbox != nil {
			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
			m.folderInbox.SetLoadingEmails(false)
		}
		return m, nil

	case tui.EmailsFetchedMsg:
		if m.emailsByAcct == nil {
			m.emailsByAcct = make(map[string][]fetcher.Email)
		}
		m.emailsByAcct[msg.AccountID] = msg.Emails
		m.emails = flattenAndSort(m.emailsByAcct)
		m.syncUnreadBadge()

		if m.folderInbox != nil {
			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
		}
		return m, nil

	case tui.FetchMoreEmailsMsg:
		if msg.AccountID == "" {
			return m, nil
		}
		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			return m, nil
		}
		limit := uint32(paginationLimit)
		if msg.Limit > 0 {
			limit = msg.Limit
		}
		folderName := folderInbox
		if m.folderInbox != nil {
			folderName = m.folderInbox.GetCurrentFolder()
		}
		return m, tea.Batch(
			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
			fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
		)

	case tui.SearchRequestedMsg:
		folderName := msg.FolderName
		if folderName == "" {
			folderName = folderInbox
		}
		return m, m.searchEmailsCmd(msg.Query, folderName, msg.AccountID)

	case tui.EmailsAppendedMsg:
		if m.emailsByAcct == nil {
			m.emailsByAcct = make(map[string][]fetcher.Email)
		}
		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
		m.emails = append(m.emails, unique...)
		m.syncUnreadBadge()
		return m, nil

	case tui.GoToSendMsg:
		hideTips := false
		if m.config != nil {
			hideTips = m.config.HideTips
		}
		var composer *tui.Composer
		if m.config != nil && len(m.config.Accounts) > 0 {
			firstAccount := m.config.GetFirstAccount()
			composer = tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
		} else {
			composer = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
		}
		m.applySpellcheckOptions(composer)
		m.current = composer
		m.current, _ = m.current.Update(m.currentWindowSize())
		m.syncPluginKeyBindings()
		return m, m.current.Init()

	case tui.GoToDraftsMsg:
		drafts := config.GetAllDrafts()
		m.current = tui.NewDrafts(drafts)
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.OpenDraftMsg:
		var accounts []config.Account
		hideTips := false
		if m.config != nil {
			accounts = m.config.Accounts
			hideTips = m.config.HideTips
		}
		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
		m.applySpellcheckOptions(composer)
		m.current = composer
		m.current, _ = m.current.Update(m.currentWindowSize())
		m.syncPluginKeyBindings()
		return m, m.current.Init()

	case tui.DeleteSavedDraftMsg:
		go func() {
			if err := config.DeleteDraft(msg.DraftID); err != nil {
				log.Printf("Error deleting draft: %v", err)
			}
		}()
		// Send message back to drafts view
		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
		return m, cmd

	case tui.GoToMarketplaceMsg:
		m.current = tui.NewMarketplace(false)
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.ConfigSavedMsg:
		if m.service != nil {
			if err := m.service.ReloadConfig(); err != nil {
				log.Printf("config reload: %v", err)
			}
		}
		if m.folderInbox != nil {
			m.folderInbox.SetDateFormat(m.config.GetDateFormat())
			m.folderInbox.SetDetailedDates(m.config.EnableDetailedDates)
			m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
			m.folderInbox.SetDisableImages(m.config.DisableImages)
		}
		return m, nil

	case tui.LanguageChangedMsg:
		// Rebuild all models with new translations
		// Keep current view type but recreate with fresh i18n
		switch curr := m.current.(type) {
		case *tui.Settings:
			// Preserve settings state when rebuilding
			newSettings := m.newSettings()
			newSettings.RestoreState(curr.GetState())
			m.current = newSettings
		case *tui.Composer:
			// Preserve composer state if possible, for now just refresh
			m.current = tui.NewChoice()
		case *tui.Inbox:
			m.current = tui.NewChoice()
		case *tui.FolderInbox:
			// Just rebuild settings view, folder inbox will be recreated on next navigation
			m.current = m.newSettings()
		default:
			// For other views, return to choice menu
			m.current = tui.NewChoice()
		}
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.GoToSettingsMsg:
		m.current = m.newSettings()
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.GoToAddAccountMsg:
		hideTips := false
		if m.config != nil {
			hideTips = m.config.HideTips
		}
		m.current = tui.NewLogin(hideTips)
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.GoToAddMailingListMsg:
		m.current = tui.NewMailingListEditor()
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.GoToEditAccountMsg:
		hideTips := false
		if m.config != nil {
			hideTips = m.config.HideTips
		}
		login := tui.NewLogin(hideTips)
		login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port, msg.CatchAll, msg.MaildirPath)
		m.current = login
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.GoToEditMailingListMsg:
		editor := tui.NewMailingListEditor()
		editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
		m.current = editor
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.SaveMailingListMsg:
		if m.config != nil {
			var addrs []string
			for _, part := range strings.Split(msg.Addresses, ",") {
				if trimmed := strings.TrimSpace(part); trimmed != "" {
					addrs = append(addrs, trimmed)
				}
			}
			if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
				m.config.MailingLists[msg.EditIndex] = config.MailingList{
					Name:      msg.Name,
					Addresses: addrs,
				}
			} else {
				m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
					Name:      msg.Name,
					Addresses: addrs,
				})
			}
			if err := config.SaveConfig(m.config); err != nil {
				log.Printf("could not save config: %v", err)
			}
		}
		// Return to settings
		m.current = m.newSettings()
		// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.GoToSignatureEditorMsg:
		m.current = tui.NewSignatureEditor(msg.AccountID)
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.PasswordVerifiedMsg:
		if msg.Err != nil {
			// Error is handled inside PasswordPrompt itself
			return m, nil
		}
		// Password verified — set session key and load config
		config.SetSessionKey(msg.Key)
		cfg, err := config.LoadConfig()
		if err == nil {
			if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
				log.Printf("warning: contacts migration failed: %v", migrateErr)
			}
			if cfg.Theme != "" {
				theme.SetTheme(cfg.Theme)
				tui.RebuildStyles()
			}
			// Set language from config
			lang := i18n.DetectLanguage(cfg)
			log.Printf("Detected language: %s", lang)
			if err := i18n.GetManager().SetLanguage(lang); err != nil {
				log.Printf("Failed to set language %s: %v", lang, err)
			} else {
				log.Printf("Language set to: %s", i18n.GetManager().GetLanguage())
				log.Printf("Test translation: %s", i18n.GetManager().T("composer.title"))
			}
		}
		_ = config.EnsurePGPDir()
		if err != nil {
			m.config = nil
			hideTips := false
			m.current = tui.NewLogin(hideTips)
		} else {
			m.config = cfg
			if m.mailtoURL != nil {
				to := m.mailtoURL.Opaque
				if to == "" {
					to = m.mailtoURL.Path
				}
				if to == "" {
					to = m.mailtoURL.Query().Get("to")
				}
				subject := m.mailtoURL.Query().Get("subject")
				body := m.mailtoURL.Query().Get("body")
				composer := tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
				m.applySpellcheckOptions(composer)
				m.current = composer
			} else {
				m.current = tui.NewChoice()
			}
		}
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.SecureModeEnabledMsg:
		if msg.Err != nil {
			log.Printf("Failed to enable encryption: %v", msg.Err)
		}
		return m, nil

	case tui.SecureModeDisabledMsg:
		if msg.Err != nil {
			log.Printf("Failed to disable encryption: %v", msg.Err)
		}
		return m, nil

	case tui.GoToChoiceMenuMsg:
		m.current = tui.NewChoice()
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.DeleteAccountMsg:
		if m.config != nil {
			if m.config.RemoveAccount(msg.AccountID) {
				if err := config.CleanupAccountCache(msg.AccountID); err != nil {
					log.Printf("could not clean account cache: %v", err)
				}
				if err := config.SaveConfig(m.config); err != nil {
					log.Printf("could not save config: %v", err)
				}
			}
			// Remove emails for this account
			delete(m.emailsByAcct, msg.AccountID)

			// Rebuild all emails
			var allEmails []fetcher.Email
			for _, emails := range m.emailsByAcct {
				allEmails = append(allEmails, emails...)
			}
			m.emails = allEmails

			// Go back to settings
			m.current = m.newSettings()
			m.current, _ = m.current.Update(m.currentWindowSize())
		}
		return m, m.current.Init()

	case tui.ViewEmailMsg:
		email := msg.Email
		if email == nil {
			email = m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
		} else {
			m.addEmailToStoresIfMissing(*email, msg.Mailbox)
		}
		if email == nil {
			return m, nil
		}
		folderName := folderInbox
		if m.folderInbox != nil {
			folderName = m.folderInbox.GetCurrentFolder()
		}
		suppressRead := false
		if m.plugins != nil {
			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
			m.plugins.CallHook(plugin.HookEmailViewed, t)
			suppressRead = m.plugins.TakeAutoReadSuppressed()
		}
		// Split pane mode: open in split view instead of full screen
		if m.config.EnableSplitPane && m.folderInbox != nil {
			m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID, email)
			m.current = m.folderInbox
			// Mark as read
			if !email.IsRead && !suppressRead {
				m.markEmailAsReadInStores(msg.UID, msg.AccountID)
				account := m.config.GetAccountByID(msg.AccountID)
				if account != nil {
					cmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
				}
			}
			// Fetch body
			return m, tea.Batch(append(m.pluginFlagCmds(), cmd, func() tea.Msg {
				return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID}
			})...)
		}
		// Check body cache first
		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil {
			// Convert cached attachments back to fetcher.Attachment
			var attachments []fetcher.Attachment
			for _, ca := range cached.Attachments {
				att := fetcher.Attachment{
					Filename:         ca.Filename,
					PartID:           ca.PartID,
					Encoding:         ca.Encoding,
					MIMEType:         ca.MIMEType,
					ContentID:        ca.ContentID,
					Inline:           ca.Inline,
					IsSMIMESignature: ca.IsSMIMESignature,
					SMIMEVerified:    ca.SMIMEVerified,
					IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
					IsCalendarInvite: ca.IsCalendarInvite,
				}
				if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
					att.Data = ca.CalendarData
				}
				attachments = append(attachments, att)
			}
			return m, func() tea.Msg {
				return tui.EmailBodyFetchedMsg{
					UID:          msg.UID,
					Body:         cached.Body,
					BodyMIMEType: cached.BodyMIMEType,
					Attachments:  attachments,
					AccountID:    msg.AccountID,
					Mailbox:      msg.Mailbox,
				}
			}
		}
		m.current = tui.NewStatus("Fetching email content...")
		return m, tea.Batch(append(m.pluginFlagCmds(), m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())...)

	case tui.EmailBodyFetchedMsg:
		if msg.Err != nil {
			log.Printf("could not fetch email body: %v", msg.Err)
			if m.folderInbox != nil {
				m.current = m.folderInbox
			}
			return m, nil
		}

		// Update the email in our stores
		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Body, msg.BodyMIMEType, msg.Attachments)

		// Cache the body to disk
		folderForCache := folderInbox
		if m.folderInbox != nil {
			folderForCache = m.folderInbox.GetCurrentFolder()
		}
		var cachedAttachments []config.CachedAttachment
		for _, a := range msg.Attachments {
			ca := config.CachedAttachment{
				Filename:         a.Filename,
				PartID:           a.PartID,
				Encoding:         a.Encoding,
				MIMEType:         a.MIMEType,
				ContentID:        a.ContentID,
				Inline:           a.Inline,
				IsSMIMESignature: a.IsSMIMESignature,
				SMIMEVerified:    a.SMIMEVerified,
				IsSMIMEEncrypted: a.IsSMIMEEncrypted,
				IsCalendarInvite: a.IsCalendarInvite,
			}
			if a.IsCalendarInvite && len(a.Data) > 0 {
				ca.CalendarData = a.Data
			}
			cachedAttachments = append(cachedAttachments, ca)
		}
		err := config.SaveEmailBody(folderForCache, config.CachedEmailBody{
			UID:          msg.UID,
			AccountID:    msg.AccountID,
			Body:         msg.Body,
			BodyMIMEType: msg.BodyMIMEType,
			Attachments:  cachedAttachments,
		}, m.config.GetBodyCacheThreshold())

		if err != nil {
			loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
		}

		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
		if email == nil {
			if m.folderInbox != nil {
				m.current = m.folderInbox
			}
			return m, nil
		}

		// Mark as read in UI immediately and on the server (unless plugin suppressed it)
		var markReadCmd tea.Cmd
		pluginSuppressed := m.plugins != nil && m.plugins.TakeAutoReadSuppressed()
		if !email.IsRead && !pluginSuppressed {
			m.markEmailAsReadInStores(msg.UID, msg.AccountID)

			folderName := folderInbox
			if m.folderInbox != nil {
				folderName = m.folderInbox.GetCurrentFolder()
			}
			account := m.config.GetAccountByID(msg.AccountID)
			if account != nil {
				markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
			}
		}

		// Find the index for the email view (used for display purposes)
		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID)
		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
		m.current = emailView
		m.syncPluginStatus()
		m.syncPluginKeyBindings()
		cmds := []tea.Cmd{m.current.Init()}
		if markReadCmd != nil {
			cmds = append(cmds, markReadCmd)
		}
		cmds = append(cmds, m.pluginFlagCmds()...)
		return m, tea.Batch(cmds...)

	case tui.ReplyToEmailMsg:
		var to string
		if len(msg.Email.ReplyTo) > 0 {
			to = strings.Join(msg.Email.ReplyTo, ", ")
		} else {
			to = msg.Email.From
		}
		subject := msg.Email.Subject
		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
		if !strings.HasPrefix(normalizedSubject, "re:") {
			subject = "Re: " + subject
		}
		quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Local().Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))

		var composer *tui.Composer
		hideTips := false
		if m.config != nil {
			hideTips = m.config.HideTips
		}
		if m.config != nil && len(m.config.Accounts) > 0 {
			// Use the account that received the email
			accountID := msg.Email.AccountID
			if accountID == "" {
				accountID = m.config.GetFirstAccount().ID
			}
			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
			// For catch-all accounts, pre-fill From with the specific address the email was delivered to.
			if len(msg.Email.To) > 0 {
				for i := range m.config.Accounts {
					if m.config.Accounts[i].ID == accountID && m.config.Accounts[i].CatchAll {
						acc := &m.config.Accounts[i]
						deliveryAddr := msg.Email.To[0]
						if addr, err := mail.ParseAddress(deliveryAddr); err == nil {
							deliveryAddr = addr.Address
						}
						fromVal := deliveryAddr
						if acc.Name != "" {
							fromVal = fmt.Sprintf("%s <%s>", acc.Name, deliveryAddr)
						}
						composer.SetFromOverride(fromVal)
						break
					}
				}
			}
		} else {
			composer = tui.NewComposer("", to, subject, "", hideTips)
		}
		composer.SetQuotedText(quotedText)

		// Set reply headers
		inReplyTo := msg.Email.MessageID
		references := append(msg.Email.References, msg.Email.MessageID) //nolint:gocritic
		composer.SetReplyContext(inReplyTo, references)

		m.applySpellcheckOptions(composer)
		m.current = composer
		m.current, _ = m.current.Update(m.currentWindowSize())
		m.syncPluginKeyBindings()
		return m, m.current.Init()

	case tui.ForwardEmailMsg:
		subject := msg.Email.Subject
		if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
			subject = "Fwd: " + subject
		}

		forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
			msg.Email.From,
			msg.Email.Date.Local().Format("Mon, Jan 2, 2006 at 3:04 PM"),
			msg.Email.Subject,
			msg.Email.To,
		)

		body := forwardHeader + msg.Email.Body

		var composer *tui.Composer
		hideTips := false
		if m.config != nil {
			hideTips = m.config.HideTips
		}
		if m.config != nil && len(m.config.Accounts) > 0 {
			// Use the account that received the email
			accountID := msg.Email.AccountID
			if accountID == "" {
				accountID = m.config.GetFirstAccount().ID
			}
			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
		} else {
			composer = tui.NewComposer("", "", subject, body, hideTips)
		}

		m.applySpellcheckOptions(composer)
		m.current = composer
		m.current, _ = m.current.Update(m.currentWindowSize())
		m.syncPluginKeyBindings()
		return m, m.current.Init()

	case tui.OpenEditorMsg:
		composer, ok := m.current.(*tui.Composer)
		if !ok {
			return m, nil
		}
		return m, openExternalEditor(composer.GetBody())

	case tui.EditorFinishedMsg:
		if msg.Err != nil {
			log.Printf("Editor error: %v", msg.Err)
			return m, nil
		}
		if composer, ok := m.current.(*tui.Composer); ok {
			composer.SetBody(msg.Body)
		}
		return m, nil

	case tui.GoToFilePickerMsg:
		if runtime.GOOS == goosDarwin {
			return m, func() tea.Msg {
				wd, _ := os.Getwd()
				paths, err := macos.OpenFilePicker(wd)
				if err != nil || len(paths) == 0 {
					return tui.CancelFilePickerMsg{}
				}
				return tui.FileSelectedMsg{Paths: paths}
			}
		}
		m.previousModel = m.current
		wd, _ := os.Getwd()
		m.current = tui.NewFilePicker(wd)
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
		if m.previousModel != nil {
			m.current = m.previousModel
			m.previousModel = nil
		}
		m.current, cmd = m.current.Update(msg)
		cmds = append(cmds, cmd)

	case tui.SendEmailMsg:
		if m.plugins != nil {
			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
		}
		// Get draft ID before clearing composer (if it's a composer)
		var draftID string
		if composer, ok := m.current.(*tui.Composer); ok {
			draftID = composer.GetDraftID()
		}
		// Get the account to send from
		var account *config.Account
		if msg.AccountID != "" && m.config != nil {
			account = m.config.GetAccountByID(msg.AccountID)
		}
		if account == nil && m.config != nil {
			account = m.config.GetFirstAccount()
		}

		statusText := "Sending email..."
		if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" {
			statusText = "Touch your YubiKey to sign..."
		}
		m.current = tui.NewStatus(statusText)

		// Save contact and delete draft in background
		go func() {
			// Save the recipient as a contact
			if msg.To != "" {
				recipients := strings.Split(msg.To, ",")
				for _, r := range recipients {
					r = strings.TrimSpace(r)
					if r == "" {
						continue
					}
					name, email := parseEmailAddress(r)
					if err := config.AddContactForAccount(name, email, msg.AccountID); err != nil {
						log.Printf("Error saving contact: %v", err)
					}
				}
			}
			// Delete the draft since email is being sent
			if draftID != "" {
				if err := config.DeleteDraft(draftID); err != nil {
					log.Printf("Error deleting draft after send: %v", err)
				}
			}
		}()

		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))

	case tui.SendRSVPMsg:
		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			m.current = tui.NewStatus("Error: account not found")
			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
				return tui.RestoreViewMsg{}
			})
		}

		m.current = tui.NewStatus("Sending RSVP...")
		return m, tea.Batch(m.current.Init(), sendRSVP(account, msg))

	case tui.RSVPResultMsg:
		if msg.Err != nil {
			log.Printf("Failed to send RSVP: %v", msg.Err)
			m.previousModel = tui.NewChoice()
			m.previousModel, _ = m.previousModel.Update(m.currentWindowSize())
			m.current = tui.NewStatus(fmt.Sprintf("RSVP error: %v", msg.Err))
			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
				return tui.RestoreViewMsg{}
			})
		}
		status := fmt.Sprintf("RSVP sent: %s", msg.Response)
		if strings.HasSuffix(strings.ToLower(msg.Organizer), "@gmail.com") || strings.HasSuffix(strings.ToLower(msg.Organizer), "@googlemail.com") {
			status += " (Google Calendar may not auto-update — use Gmail buttons for Google events)"
		}
		m.current = tui.NewStatus(status)
		return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg {
			return tui.RestoreViewMsg{}
		})

	case tui.EmailResultMsg:
		if msg.Err != nil {
			log.Printf("Failed to send email: %v", msg.Err)
			m.previousModel = tui.NewChoice()
			m.previousModel, _ = m.previousModel.Update(m.currentWindowSize())
			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
				return tui.RestoreViewMsg{}
			})
		}
		if m.plugins != nil {
			m.plugins.CallHook(plugin.HookEmailSendAfter)
		}
		m.current = tui.NewChoice()
		m.current, _ = m.current.Update(m.currentWindowSize())
		return m, m.current.Init()

	case tui.DeleteEmailMsg:
		tui.ClearKittyGraphics()

		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			if m.folderInbox != nil {
				m.current = m.folderInbox
			}
			return m, nil
		}

		folderName := folderInbox
		if m.folderInbox != nil {
			m.current = m.folderInbox
			folderName = m.folderInbox.GetCurrentFolder()
			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
		}

		m.removeEmailFromStores(msg.UID, msg.AccountID)

		if emails, ok := m.folderEmails[folderName]; ok {
			var filtered []fetcher.Email
			for _, e := range emails {
				if e.UID != msg.UID || e.AccountID != msg.AccountID {
					filtered = append(filtered, e)
				}
			}
			m.folderEmails[folderName] = filtered
			go saveFolderEmailsToCache(folderName, filtered)
		}

		return m, m.deleteFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox)

	case tui.ArchiveEmailMsg:
		tui.ClearKittyGraphics()

		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			if m.folderInbox != nil {
				m.current = m.folderInbox
			}
			return m, nil
		}

		folderName := folderInbox
		if m.folderInbox != nil {
			m.current = m.folderInbox
			folderName = m.folderInbox.GetCurrentFolder()
			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
		}

		m.removeEmailFromStores(msg.UID, msg.AccountID)

		if emails, ok := m.folderEmails[folderName]; ok {
			var filtered []fetcher.Email
			for _, e := range emails {
				if e.UID != msg.UID || e.AccountID != msg.AccountID {
					filtered = append(filtered, e)
				}
			}
			m.folderEmails[folderName] = filtered
			go saveFolderEmailsToCache(folderName, filtered)
		}

		return m, m.archiveFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox)

	case tui.EmailMarkedReadMsg:
		if msg.Err != nil {
			log.Printf("Error marking email as read: %v", msg.Err)
		}
		m.syncUnreadBadge()
		return m, nil

	case tui.EmailMarkedUnreadMsg:
		if msg.Err != nil {
			log.Printf("Error marking email as unread: %v", msg.Err)
		}
		m.syncUnreadBadge()
		return m, nil

	case tui.EmailActionDoneMsg:
		if msg.Err != nil {
			log.Printf("Action failed: %v", msg.Err)
			if m.folderInbox != nil {
				m.previousModel = m.folderInbox
			}
			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
				return tui.RestoreViewMsg{}
			})
		}

		return m, nil

	case tui.BatchDeleteEmailsMsg:
		tui.ClearKittyGraphics()

		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			if m.folderInbox != nil {
				m.current = m.folderInbox
			}
			return m, nil
		}

		folderName := folderInbox
		if m.folderInbox != nil {
			folderName = m.folderInbox.GetCurrentFolder()
			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
		}

		for _, uid := range msg.UIDs {
			m.removeEmailFromStores(uid, msg.AccountID)
		}

		if emails, ok := m.folderEmails[folderName]; ok {
			var filtered []fetcher.Email
			for _, e := range emails {
				if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
					filtered = append(filtered, e)
				}
			}
			m.folderEmails[folderName] = filtered
			go saveFolderEmailsToCache(folderName, filtered)
		}

		return m, m.batchDeleteEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs))

	case tui.BatchArchiveEmailsMsg:
		tui.ClearKittyGraphics()

		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			if m.folderInbox != nil {
				m.current = m.folderInbox
			}
			return m, nil
		}

		folderName := folderInbox
		if m.folderInbox != nil {
			folderName = m.folderInbox.GetCurrentFolder()
			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
		}

		for _, uid := range msg.UIDs {
			m.removeEmailFromStores(uid, msg.AccountID)
		}

		if emails, ok := m.folderEmails[folderName]; ok {
			var filtered []fetcher.Email
			for _, e := range emails {
				if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
					filtered = append(filtered, e)
				}
			}
			m.folderEmails[folderName] = filtered
			go saveFolderEmailsToCache(folderName, filtered)
		}

		return m, m.batchArchiveEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs))

	case tui.BatchMoveEmailsMsg:
		if m.config == nil {
			return m, nil
		}
		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			return m, nil
		}

		folderName := folderInbox
		if m.folderInbox != nil {
			folderName = m.folderInbox.GetCurrentFolder()
			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
		}

		for _, uid := range msg.UIDs {
			m.removeEmailFromStores(uid, msg.AccountID)
		}

		if emails, ok := m.folderEmails[folderName]; ok {
			var filtered []fetcher.Email
			for _, e := range emails {
				if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
					filtered = append(filtered, e)
				}
			}
			m.folderEmails[folderName] = filtered
			go saveFolderEmailsToCache(folderName, filtered)
		}

		return m, m.batchMoveEmailsCmd(msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, len(msg.UIDs))

	case tui.BatchEmailActionDoneMsg:
		if msg.Err != nil {
			log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
				return tui.RestoreViewMsg{}
			})
		}

		return m, nil

	case tui.DownloadAttachmentMsg:
		m.previousModel = m.current
		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))

		account := m.config.GetAccountByID(msg.AccountID)
		if account == nil {
			m.current = m.previousModel
			return m, nil
		}

		email := m.getEmailByIndex(msg.Index)
		if email == nil {
			m.current = m.previousModel
			return m, nil
		}

		// Find the correct attachment to get encoding
		var encoding string
		for _, att := range email.Attachments {
			if att.PartID == msg.PartID {
				encoding = att.Encoding
				break
			}
		}
		newMsg := tui.DownloadAttachmentMsg{
			Index:     msg.Index,
			Filename:  msg.Filename,
			PartID:    msg.PartID,
			Data:      msg.Data,
			AccountID: msg.AccountID,
			Encoding:  encoding,
			Mailbox:   msg.Mailbox,
		}
		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))

	case tui.AttachmentDownloadedMsg:
		var statusMsg string
		if msg.Err != nil {
			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
		} else {
			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
		}
		m.current = tui.NewStatus(statusMsg)
		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
			return tui.RestoreViewMsg{}
		})

	case tui.RestoreViewMsg:
		if m.previousModel != nil {
			m.current = m.previousModel
			m.previousModel = nil
		}
		return m, nil
	}

	if cmd := m.pluginNotifyCmd(); cmd != nil {
		cmds = append(cmds, cmd)
	}

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

func (m *mainModel) View() tea.View {
	v := m.current.View()
	if m.showLogPanel {
		v.Content = m.renderWithLogPanel(v.Content)
	}
	v.AltScreen = true
	return v
}

func (m *mainModel) currentWindowSize() tea.WindowSizeMsg {
	return tea.WindowSizeMsg{
		Width:  m.width,
		Height: m.contentHeight(),
	}
}

func (m *mainModel) contentHeight() int {
	height := m.height - m.logPanelHeight()
	if height < 1 {
		return 1
	}
	return height
}

func (m *mainModel) renderWithLogPanel(content string) string {
	panelHeight := m.logPanelHeight()
	if panelHeight == 0 {
		return content
	}

	contentHeight := m.contentHeight()

	mainContent := lipgloss.NewStyle().
		MaxHeight(contentHeight).
		Height(contentHeight).
		Render(content)

	if m.logPanel == nil {
		return mainContent
	}
	m.logPanel.SetSize(m.width, panelHeight)
	return lipgloss.JoinVertical(lipgloss.Left, mainContent, m.logPanel.View())
}

func (m *mainModel) logPanelHeight() int {
	if !m.showLogPanel || m.height < 12 || m.width < 20 {
		return 0
	}
	if m.height < 20 {
		return 4
	}
	return 7
}

func (m *mainModel) getEmailByIndex(index int) *fetcher.Email {
	if index >= 0 && index < len(m.emails) {
		return &m.emails[index]
	}
	return nil
}

func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email {
	for i := range m.emails {
		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
			return &m.emails[i]
		}
	}
	return nil
}

func (m *mainModel) getEmailIndex(uid uint32, accountID string) int {
	for i := range m.emails {
		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
			return i
		}
	}
	return -1
}

func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body, bodyMIMEType string, attachments []fetcher.Attachment) {
	for i := range m.emails {
		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
			m.emails[i].Body = body
			m.emails[i].BodyMIMEType = bodyMIMEType
			m.emails[i].Attachments = attachments
			break
		}
	}
	if emails, ok := m.emailsByAcct[accountID]; ok {
		for i := range emails {
			if emails[i].UID == uid {
				emails[i].Body = body
				emails[i].BodyMIMEType = bodyMIMEType
				emails[i].Attachments = attachments
				break
			}
		}
	}
}

func (m *mainModel) addEmailToStoresIfMissing(email fetcher.Email, _ tui.MailboxKind) {
	if m.getEmailByUIDAndAccount(email.UID, email.AccountID) != nil {
		return
	}
	if m.emailsByAcct == nil {
		m.emailsByAcct = make(map[string][]fetcher.Email)
	}
	m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
	m.emails = flattenAndSort(m.emailsByAcct)
}

func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
	for i := range m.emails {
		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
			m.emails[i].IsRead = true
			break
		}
	}
	if emails, ok := m.emailsByAcct[accountID]; ok {
		for i := range emails {
			if emails[i].UID == uid {
				emails[i].IsRead = true
				break
			}
		}
	}
	// Update folder email cache
	for folderName, folderEmails := range m.folderEmails {
		for i := range folderEmails {
			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
				folderEmails[i].IsRead = true
				m.folderEmails[folderName] = folderEmails
				go saveFolderEmailsToCache(folderName, folderEmails)
				break
			}
		}
	}
	// Update the inbox UI
	if m.folderInbox != nil {
		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)

		for folderName, folderEmails := range m.folderEmails {
			for _, e := range folderEmails {
				if e.UID == uid && e.AccountID == accountID {
					m.folderInbox.DecrementUnreadCount(folderName)
					config.SaveAccountFolders(accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
					return
				}
			}
		}
	}
}

func (m *mainModel) markEmailAsUnreadInStores(uid uint32, accountID string) {
	for i := range m.emails {
		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
			m.emails[i].IsRead = false
			break
		}
	}
	if emails, ok := m.emailsByAcct[accountID]; ok {
		for i := range emails {
			if emails[i].UID == uid {
				emails[i].IsRead = false
				break
			}
		}
	}
	for folderName, folderEmails := range m.folderEmails {
		for i := range folderEmails {
			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
				folderEmails[i].IsRead = false
				m.folderEmails[folderName] = folderEmails
				go saveFolderEmailsToCache(folderName, folderEmails)
				break
			}
		}
	}
	if m.folderInbox != nil {
		m.folderInbox.GetInbox().MarkEmailAsUnread(uid, accountID)
	}
}

func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
	var filtered []fetcher.Email
	for _, e := range m.emails {
		if e.UID != uid || e.AccountID != accountID {
			filtered = append(filtered, e)
		}
	}
	m.emails = filtered
	if emails, ok := m.emailsByAcct[accountID]; ok {
		var filteredAcct []fetcher.Email
		for _, e := range emails {
			if e.UID != uid {
				filteredAcct = append(filteredAcct, e)
			}
		}
		m.emailsByAcct[accountID] = filteredAcct
	}
}

// pluginFlagCmds drains pending flag ops from plugins and returns the corresponding tea.Cmds.
func (m *mainModel) pluginFlagCmds() []tea.Cmd {
	if m.plugins == nil {
		return nil
	}
	ops := m.plugins.TakePendingFlagOps()
	if len(ops) == 0 {
		return nil
	}
	var cmds []tea.Cmd
	for _, op := range ops {
		account := m.config.GetAccountByID(op.AccountID)
		if account == nil {
			continue
		}
		if op.Read {
			m.markEmailAsReadInStores(op.UID, op.AccountID)
			cmds = append(cmds, markEmailAsReadCmd(account, op.UID, op.AccountID, op.Folder))
		} else {
			m.markEmailAsUnreadInStores(op.UID, op.AccountID)
			cmds = append(cmds, markEmailAsUnreadCmd(account, op.UID, op.AccountID, op.Folder))
		}
	}
	return cmds
}

// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
func (m *mainModel) pluginNotifyCmd() tea.Cmd {
	if m.plugins == nil {
		return nil
	}
	if n, ok := m.plugins.TakePendingNotification(); ok {
		return func() tea.Msg {
			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
		}
	}
	return nil
}

func (m *mainModel) syncPluginStatus() {
	if m.plugins == nil {
		return
	}
	if m.folderInbox != nil {
		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
	}
	switch v := m.current.(type) {
	case *tui.Composer:
		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
	case *tui.EmailView:
		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
	}
}

func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) tea.Cmd {
	keyStr := msg.String()

	var area string
	switch m.current.(type) {
	case *tui.Inbox:
		area = plugin.StatusInbox
	case *tui.FolderInbox:
		area = plugin.StatusInbox
	case *tui.EmailView:
		area = plugin.StatusEmailView
	case *tui.Composer:
		area = plugin.StatusComposer
	default:
		return nil
	}

	bindings := m.plugins.Bindings(area)
	for _, binding := range bindings {
		if binding.Key != keyStr {
			continue
		}

		// Build context table based on the current view
		switch v := m.current.(type) {
		case *tui.Inbox:
			if email := v.GetSelectedEmail(); email != nil {
				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
				m.plugins.CallKeyBinding(binding, t)
			} else {
				m.plugins.CallKeyBinding(binding)
			}
		case *tui.FolderInbox:
			if email := v.GetInbox().GetSelectedEmail(); email != nil {
				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
				m.plugins.CallKeyBinding(binding, t)
			} else {
				m.plugins.CallKeyBinding(binding)
			}
		case *tui.EmailView:
			email := v.GetEmail()
			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
			m.plugins.CallKeyBinding(binding, t)
		case *tui.Composer:
			L := m.plugins.LuaState()
			t := L.NewTable()
			t.RawSetString("body", lua.LString(v.GetBody()))
			t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
			t.RawSetString("subject", lua.LString(v.GetSubject()))
			t.RawSetString("to", lua.LString(v.GetTo()))
			t.RawSetString("cc", lua.LString(v.GetCc()))
			t.RawSetString("bcc", lua.LString(v.GetBcc()))
			m.plugins.CallKeyBinding(binding, t)
			m.applyPluginFields(v)

			// Check if the plugin requested a prompt overlay
			if p, ok := m.plugins.TakePendingPrompt(); ok {
				m.pendingPrompt = p
				v.ShowPluginPrompt(p.Placeholder)
			}
		}

		m.syncPluginStatus()
		return tea.Batch(m.pluginFlagCmds()...)
	}
	return nil
}

func (m *mainModel) syncPluginKeyBindings() {
	if m.plugins == nil {
		return
	}

	toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
		result := make([]tui.PluginKeyBinding, len(bindings))
		for i, b := range bindings {
			result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
		}
		return result
	}

	if m.folderInbox != nil {
		m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
	}
	switch v := m.current.(type) {
	case *tui.Composer:
		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
	case *tui.EmailView:
		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
	}
}

func (m *mainModel) applyPluginFields(composer *tui.Composer) {
	fields := m.plugins.TakePendingFields()
	if fields == nil {
		return
	}
	for field, value := range fields {
		switch field {
		case "to":
			composer.SetTo(value)
		case "cc":
			composer.SetCc(value)
		case "bcc":
			composer.SetBcc(value)
		case "subject":
			composer.SetSubject(value)
		case "body":
			composer.SetBody(value)
		}
	}
}

func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
	var allEmails []fetcher.Email
	for _, emails := range emailsByAccount {
		allEmails = append(allEmails, emails...)
	}
	for i := 0; i < len(allEmails); i++ {
		for j := i + 1; j < len(allEmails); j++ {
			if allEmails[j].Date.After(allEmails[i].Date) {
				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
			}
		}
	}
	return allEmails
}

func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accountID string) tea.Cmd {
	return func() tea.Msg {
		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPSearchTimeout)
		defer cancel()

		var accounts []config.Account
		for _, acc := range m.config.Accounts {
			if accountID == "" || acc.ID == accountID {
				accounts = append(accounts, acc)
			}
		}

		var results []fetcher.Email
		var firstErr error
		succeeded := false
		for i := range accounts {
			acc := &accounts[i]
			p := m.getProvider(acc)
			if p == nil {
				if firstErr == nil {
					firstErr = fmt.Errorf("provider not found for account %s", acc.ID)
				}
				continue
			}
			emails, err := p.Search(ctx, folderName, query)
			if err != nil {
				if errors.Is(err, backend.ErrNotSupported) {
					continue
				}
				if firstErr == nil {
					firstErr = err
				}
				continue
			}
			succeeded = true
			results = append(results, backendEmailsToFetcher(emails)...)
		}
		if !succeeded && firstErr != nil {
			return tui.SearchResultsMsg{Query: query, Err: firstErr}
		}
		sortFetcherEmails(results)

		return tui.SearchResultsMsg{Query: query, Emails: results}
	}
}

func backendEmailsToFetcher(emails []backend.Email) []fetcher.Email {
	result := make([]fetcher.Email, len(emails))
	for i, e := range emails {
		result[i] = fetcher.Email{
			UID: e.UID, From: e.From, To: e.To, ReplyTo: e.ReplyTo,
			Subject: e.Subject, Body: e.Body, Date: e.Date, IsRead: e.IsRead,
			MessageID: e.MessageID, References: e.References, AccountID: e.AccountID,
		}
	}
	return result
}

func sortFetcherEmails(emails []fetcher.Email) {
	sort.Slice(emails, func(i, j int) bool {
		if emails[i].Date.Equal(emails[j].Date) {
			return emails[i].UID > emails[j].UID
		}
		return emails[i].Date.After(emails[j].Date)
	})
}

func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
	return func() tea.Msg {
		emailsByAccount := make(map[string][]fetcher.Email)
		var mu sync.Mutex
		var wg sync.WaitGroup

		for _, account := range cfg.Accounts {
			wg.Add(1)
			go func(acc config.Account) {
				defer wg.Done()
				var emails []fetcher.Email
				var err error

				limit := uint32(initialEmailLimit)
				if counts != nil {
					if c, ok := counts[acc.ID]; ok && c > 0 {
						limit = uint32(c)
					}
				}

				if mailbox == tui.MailboxSent {
					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
				} else {
					emails, err = fetcher.FetchEmails(&acc, limit, 0)
				}
				if err != nil {
					log.Printf("Error fetching from %s: %v", acc.Email, err)
					return
				}
				mu.Lock()
				emailsByAccount[acc.ID] = emails
				mu.Unlock()
			}(account)
		}

		wg.Wait()
		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
	}
}

func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
	cached := make([]config.CachedEmail, 0, len(emails))
	for _, email := range emails {
		cached = append(cached, config.CachedEmail{
			UID:        email.UID,
			From:       email.From,
			To:         email.To,
			Subject:    email.Subject,
			Date:       email.Date,
			MessageID:  email.MessageID,
			InReplyTo:  email.InReplyTo,
			References: email.References,
			AccountID:  email.AccountID,
			IsRead:     email.IsRead,
		})
	}
	return cached
}

func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
	emails := make([]fetcher.Email, 0, len(cached))
	for _, c := range cached {
		emails = append(emails, fetcher.Email{
			UID:        c.UID,
			From:       c.From,
			To:         c.To,
			Subject:    c.Subject,
			Date:       c.Date,
			MessageID:  c.MessageID,
			InReplyTo:  c.InReplyTo,
			References: c.References,
			AccountID:  c.AccountID,
			IsRead:     c.IsRead,
		})
	}
	return emails
}

func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
	cached := emailsToCache(emails)
	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
	}
}

func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
	cached, err := config.LoadFolderEmailCache(folderName)
	if err != nil {
		return nil
	}
	return cacheToEmails(cached)
}

// parseEmailAddress parses "Name <email>" or just "email" format
func parseEmailAddress(addr string) (name, email string) {
	addr = strings.TrimSpace(addr)
	if idx := strings.Index(addr, "<"); idx != -1 {
		name = strings.TrimSpace(addr[:idx])
		endIdx := strings.Index(addr, ">")
		if endIdx > idx {
			email = strings.TrimSpace(addr[idx+1 : endIdx])
		} else {
			email = strings.TrimSpace(addr[idx+1:])
		}
	} else {
		email = addr
	}
	return name, email
}

func markdownToHTML(md []byte) []byte {
	return clib.MarkdownToHTML(md)
}

func splitEmails(s string) []string {
	if s == "" {
		return nil
	}
	parts := strings.Split(s, ",")
	var res []string
	for _, p := range parts {
		if trimmed := strings.TrimSpace(p); trimmed != "" {
			res = append(res, trimmed)
		}
	}
	return res
}

func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
	return func() tea.Msg {
		if account == nil {
			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
		}

		// Apply custom From address for catch-all accounts.
		if msg.FromOverride != "" {
			acc := *account
			acc.SendAsEmail = msg.FromOverride
			account = &acc
		}

		recipients := splitEmails(msg.To)
		cc := splitEmails(msg.Cc)
		bcc := splitEmails(msg.Bcc)
		body := msg.Body
		// Append signature if present
		if msg.Signature != "" {
			body = body + "\n\n" + msg.Signature
		}
		// Append quoted text if present (for replies)
		if msg.QuotedText != "" {
			body += msg.QuotedText
		}
		images := make(map[string][]byte)
		attachments := make(map[string][]byte)

		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
		matches := re.FindAllStringSubmatch(body, -1)

		for _, match := range matches {
			imgPath := match[1]
			imgData, err := os.ReadFile(imgPath)
			if err != nil {
				log.Printf("Could not read image file %s: %v", imgPath, err)
				continue
			}
			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
		}

		htmlBody := markdownToHTML([]byte(body))

		for _, attachPath := range msg.AttachmentPaths {
			fileData, err := os.ReadFile(attachPath)
			if err != nil {
				log.Printf("Could not read attachment file %s: %v", attachPath, err)
				continue
			}
			_, filename := filepath.Split(attachPath)
			attachments[filename] = fileData
		}

		rawMsg, err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, false)
		if err != nil {
			log.Printf("Failed to send email: %v", err)
			return tui.EmailResultMsg{Err: err}
		}

		// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
		if account.ServiceProvider != "gmail" {
			if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
				log.Printf("Failed to append sent message to Sent folder: %v", err)
			}
		}

		return tui.EmailResultMsg{}
	}
}

func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd {
	return func() tea.Msg {
		if account == nil {
			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
		}

		// Generate RSVP .ics
		rsvpICS, err := calendar.GenerateRSVP(msg.OriginalICS, account.Email, msg.Response)
		if err != nil {
			return tui.EmailResultMsg{Err: fmt.Errorf("generate RSVP: %w", err)}
		}

		// Compose reply email
		subject := fmt.Sprintf("Re: %s", msg.Event.Summary)
		bodyText := fmt.Sprintf("%s: %s\n\n%s",
			msg.Response,
			msg.Event.Summary,
			msg.Event.Start.Local().Format("Mon Jan 2, 2006 3:04 PM"))
		if msg.Event.Location != "" {
			bodyText += " at " + msg.Event.Location
		}

		// Send as multipart/alternative with text/calendar; method=REPLY
		// This iMIP format is required for Google Calendar to recognize the RSVP
		references := append(msg.References, msg.InReplyTo) //nolint:gocritic
		rawMsg, err := sender.SendCalendarReply(
			account,
			[]string{msg.Event.Organizer},
			subject,
			bodyText,
			rsvpICS,
			msg.InReplyTo,
			references,
		)

		if err != nil {
			return tui.RSVPResultMsg{Err: fmt.Errorf("send RSVP: %w", err), Response: msg.Response, Organizer: msg.Event.Organizer}
		}

		// Append to Sent folder
		if account.ServiceProvider != "gmail" {
			if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
				log.Printf("Failed to append RSVP to Sent folder: %v", err)
			}
		}

		return tui.RSVPResultMsg{Response: msg.Response, Organizer: msg.Event.Organizer}
	}
}

// --- External editor command ---

// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
func openExternalEditor(body string) tea.Cmd {
	editor := os.Getenv("EDITOR")
	if editor == "" {
		editor = os.Getenv("VISUAL")
	}
	if editor == "" {
		editor = "vi"
	}

	tmpFile, err := os.CreateTemp("", "matcha-*.md")
	if err != nil {
		return func() tea.Msg {
			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
		}
	}
	tmpPath := tmpFile.Name()

	if _, err := tmpFile.WriteString(body); err != nil {
		writeErr := err
		if err := tmpFile.Close(); err != nil {
			_ = os.Remove(tmpPath)
			return func() tea.Msg {
				return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file after write failure: %w", err)}
			}
		}
		_ = os.Remove(tmpPath)
		return func() tea.Msg {
			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", writeErr)}
		}
	}
	if err := tmpFile.Close(); err != nil {
		_ = os.Remove(tmpPath)
		return func() tea.Msg {
			return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file: %w", err)}
		}
	}

	parts := strings.Fields(editor)
	args := append(parts[1:], tmpPath)   //nolint:gocritic
	c := exec.Command(parts[0], args...) //nolint:gosec,noctx
	return tea.ExecProcess(c, func(err error) tea.Msg {
		defer func() {
			_ = os.Remove(tmpPath)
		}()
		if err != nil {
			return tui.EditorFinishedMsg{Err: err}
		}
		content, readErr := os.ReadFile(tmpPath)
		if readErr != nil {
			return tui.EditorFinishedMsg{Err: readErr}
		}
		return tui.EditorFinishedMsg{Body: string(content)}
	})
}

// --- IDLE command ---

// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
	return func() tea.Msg {
		update, ok := <-ch
		if !ok {
			return nil
		}
		return tui.IdleNewMailMsg{
			AccountID:  update.AccountID,
			FolderName: update.FolderName,
		}
	}
}

// --- Daemon event listener ---

// listenForDaemonEvents blocks until a daemon event arrives, then returns it as a tea.Msg.
func listenForDaemonEvents(ch <-chan *daemonrpc.Event) tea.Cmd {
	return func() tea.Msg {
		ev, ok := <-ch
		if !ok {
			return nil
		}
		return tui.DaemonEventMsg{Event: ev}
	}
}

// --- Folder-based command functions ---

func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
	return func() tea.Msg {
		if !cfg.HasAccounts() {
			return nil
		}
		foldersByAccount := make(map[string][]fetcher.Folder)
		errsByAccount := make(map[string]error)
		seen := make(map[string]fetcher.Folder)
		var mu sync.Mutex
		var wg sync.WaitGroup

		for _, account := range cfg.Accounts {
			wg.Add(1)
			go func(acc config.Account) {
				defer wg.Done()
				folders, err := fetcher.FetchFolders(&acc)
				if err != nil {
					mu.Lock()
					errsByAccount[acc.ID] = err
					mu.Unlock()
					return
				}
				mu.Lock()
				foldersByAccount[acc.ID] = folders
				for _, f := range folders {
					if _, ok := seen[f.Name]; !ok {
						seen[f.Name] = f
					}
				}
				mu.Unlock()
			}(account)
		}
		wg.Wait()

		var merged []fetcher.Folder
		for _, f := range seen {
			merged = append(merged, f)
		}

		return tui.FoldersFetchedMsg{
			FoldersByAccount: foldersByAccount,
			MergedFolders:    merged,
			Errors:           errsByAccount,
		}
	}
}

func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
	return func() tea.Msg {
		emailsByAccount := make(map[string][]fetcher.Email)
		var mu sync.Mutex
		var wg sync.WaitGroup

		for _, account := range cfg.Accounts {
			wg.Add(1)
			go func(acc config.Account) {
				defer wg.Done()
				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
				if err != nil {
					// Folder may not exist for this account — silently skip
					return
				}
				mu.Lock()
				emailsByAccount[acc.ID] = emails
				mu.Unlock()
			}(account)
		}

		wg.Wait()

		// Flatten all account emails
		var allEmails []fetcher.Email
		for _, emails := range emailsByAccount {
			allEmails = append(allEmails, emails...)
		}
		// Sort newest first
		for i := 0; i < len(allEmails); i++ {
			for j := i + 1; j < len(allEmails); j++ {
				if allEmails[j].Date.After(allEmails[i].Date) {
					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
				}
			}
		}

		return tui.FolderEmailsFetchedMsg{
			Emails:     allEmails,
			FolderName: folderName,
		}
	}
}

func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
	return func() tea.Msg {
		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
		if err != nil {
			return tui.FetchErr(err)
		}
		return tui.FolderEmailsAppendedMsg{
			Emails:     emails,
			AccountID:  account.ID,
			FolderName: folderName,
		}
	}
}

func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
	return func() tea.Msg {
		account := cfg.GetAccountByID(accountID)
		if account == nil {
			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
		}

		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
		if err != nil {
			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
		}

		return tui.EmailBodyFetchedMsg{
			UID:          uid,
			Body:         body,
			BodyMIMEType: bodyMIMEType,
			Attachments:  attachments,
			AccountID:    accountID,
			Mailbox:      mailbox,
		}
	}
}

func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd {
	return func() tea.Msg {
		account := cfg.GetAccountByID(accountID)
		if account == nil {
			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
		}

		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
		if err != nil {
			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
		}

		return tui.PreviewBodyFetchedMsg{
			UID:          uid,
			Body:         body,
			BodyMIMEType: bodyMIMEType,
			Attachments:  attachments,
			AccountID:    accountID,
		}
	}
}

func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
	return func() tea.Msg {
		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
	}
}

func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
	return func() tea.Msg {
		err := fetcher.MarkEmailAsUnreadInMailbox(account, folderName, uid)
		return tui.EmailMarkedUnreadMsg{UID: uid, AccountID: accountID, Err: err}
	}
}

func (m *mainModel) deleteFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
	return func() tea.Msg {
		if m.service == nil {
			return tui.EmailActionDoneMsg{
				UID:       uid,
				AccountID: accountID,
				Mailbox:   mailbox,
				Err:       fmt.Errorf("service not initialized"),
			}
		}
		err := m.service.DeleteEmails(accountID, folderName, []uint32{uid})
		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
	}
}

func (m *mainModel) archiveFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
	return func() tea.Msg {
		if m.service == nil {
			return tui.EmailActionDoneMsg{
				UID:       uid,
				AccountID: accountID,
				Mailbox:   mailbox,
				Err:       fmt.Errorf("service not initialized"),
			}
		}
		err := m.service.ArchiveEmails(accountID, folderName, []uint32{uid})
		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
	}
}

func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
	return func() tea.Msg {
		if m.service == nil {
			return tui.BatchEmailActionDoneMsg{
				Count:        count,
				SuccessCount: 0,
				FailureCount: count,
				Action:       "delete",
				Mailbox:      mailbox,
				Err:          fmt.Errorf("service not initialized"),
			}
		}

		err := m.service.DeleteEmails(accountID, folderName, uids)

		successCount, failureCount := count, 0
		if err != nil {
			successCount, failureCount = 0, count
		}

		return tui.BatchEmailActionDoneMsg{
			Count:        count,
			SuccessCount: successCount,
			FailureCount: failureCount,
			Action:       "delete",
			Mailbox:      mailbox,
			Err:          err,
		}
	}
}

func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
	return func() tea.Msg {
		if m.service == nil {
			return tui.BatchEmailActionDoneMsg{
				Count:        count,
				SuccessCount: 0,
				FailureCount: count,
				Action:       "archive",
				Mailbox:      mailbox,
				Err:          fmt.Errorf("service not initialized"),
			}
		}

		err := m.service.ArchiveEmails(accountID, folderName, uids)

		successCount, failureCount := count, 0
		if err != nil {
			successCount, failureCount = 0, count
		}

		return tui.BatchEmailActionDoneMsg{
			Count:        count,
			SuccessCount: successCount,
			FailureCount: failureCount,
			Action:       "archive",
			Mailbox:      mailbox,
			Err:          err,
		}
	}
}

func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
	return func() tea.Msg {
		if m.service == nil {
			return tui.BatchEmailActionDoneMsg{
				Count:        count,
				SuccessCount: 0,
				FailureCount: count,
				Action:       "move",
				Err:          fmt.Errorf("service not initialized"),
			}
		}

		err := m.service.MoveEmails(accountID, uids, sourceFolder, destFolder)

		successCount, failureCount := count, 0
		if err != nil {
			successCount, failureCount = 0, count
		}

		return tui.BatchEmailActionDoneMsg{
			Count:        count,
			SuccessCount: successCount,
			FailureCount: failureCount,
			Action:       "move",
			Err:          err,
		}
	}
}

func (m *mainModel) moveEmailToFolderCmd(uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
	return func() tea.Msg {
		if m.service == nil {
			return tui.EmailMovedMsg{
				UID:          uid,
				AccountID:    accountID,
				SourceFolder: sourceFolder,
				DestFolder:   destFolder,
				Err:          fmt.Errorf("service not initialized"),
			}
		}

		err := m.service.MoveEmails(accountID, []uint32{uid}, sourceFolder, destFolder)
		return tui.EmailMovedMsg{
			UID:          uid,
			AccountID:    accountID,
			SourceFolder: sourceFolder,
			DestFolder:   destFolder,
			Err:          err,
		}
	}
}

// sanitizeFilename prevents path traversal attacks on attachment downloads.
// Email attachment filenames come from untrusted email headers and could
// contain path separators or ".." sequences to escape the Downloads directory.
func sanitizeFilename(name string) string {
	// Normalize backslashes to forward slashes so filepath.Base works
	// correctly on all platforms (Linux doesn't treat \ as a separator)
	name = strings.ReplaceAll(name, "\\", "/")
	// Strip any path components, keep only the base filename
	name = filepath.Base(name)
	// Replace any remaining path separators (defensive)
	name = strings.ReplaceAll(name, "/", "_")
	name = strings.ReplaceAll(name, "..", "_")
	// Reject hidden files and empty names
	if name == "" || name == "." || strings.HasPrefix(name, ".") {
		name = "attachment"
	}
	// Sanitize filename: enforce length limit to prevent filesystem errors
	// with extremely long names from untrusted email headers.
	const maxFilenameLen = 255
	if len(name) > maxFilenameLen {
		ext := filepath.Ext(name)
		if len(ext) > maxFilenameLen {
			ext = truncateUTF8(ext, maxFilenameLen)
		}
		base := strings.TrimSuffix(name, ext)
		name = truncateUTF8(base, maxFilenameLen-len(ext)) + ext
	}
	return name
}

func truncateUTF8(s string, maxBytes int) string {
	if maxBytes <= 0 {
		return ""
	}
	if len(s) <= maxBytes {
		return s
	}
	s = s[:maxBytes]
	for !utf8.ValidString(s) {
		_, size := utf8.DecodeLastRuneInString(s)
		s = s[:len(s)-size]
	}
	return s
}

func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
	return func() tea.Msg {
		// Download and decode the attachment using encoding provided in msg.Encoding.
		var data []byte
		var err error
		switch msg.Mailbox {
		case tui.MailboxSent:
			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
		case tui.MailboxTrash:
			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
		case tui.MailboxArchive:
			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
		case tui.MailboxInbox:
			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
		}

		if err != nil {
			return tui.AttachmentDownloadedMsg{Err: err}
		}

		homeDir, err := os.UserHomeDir()
		if err != nil {
			return tui.AttachmentDownloadedMsg{Err: err}
		}
		downloadsPath := filepath.Join(homeDir, "Downloads")
		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
			if mkErr := os.MkdirAll(downloadsPath, 0750); mkErr != nil {
				return tui.AttachmentDownloadedMsg{Err: mkErr}
			}
		}

		// Save the attachment using an exclusive create so we never overwrite an existing file.
		// If the filename already exists, append \" (n)\" before the extension.
		origName := sanitizeFilename(msg.Filename)
		ext := filepath.Ext(origName)
		base := strings.TrimSuffix(origName, ext)
		candidate := origName
		i := 1
		var filePath string

		for {
			filePath = filepath.Join(downloadsPath, candidate)

			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
			// that satisfies os.IsExist(err), so we can increment the candidate.
			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) //nolint:gosec
			if err != nil {
				if os.IsExist(err) {
					// file exists, try next candidate
					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
					i++
					continue
				}
				// Some other error while attempting to create file
				log.Printf("error creating file %s: %v", filePath, err)
				return tui.AttachmentDownloadedMsg{Err: err}
			}

			// Successfully created the file descriptor; write and close.
			if _, writeErr := f.Write(data); writeErr != nil {
				_ = f.Close()
				log.Printf("error writing to file %s: %v", filePath, writeErr)
				return tui.AttachmentDownloadedMsg{Err: writeErr}
			}
			if closeErr := f.Close(); closeErr != nil {
				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
			}

			// file saved successfully
			break
		}

		log.Printf("attachment saved to %s", filePath)

		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
		go func(p string) {
			var cmd *exec.Cmd
			switch runtime.GOOS {
			case goosDarwin:
				cmd = exec.Command("open", p) //nolint:noctx
			case "linux":
				cmd = exec.Command("xdg-open", p) //nolint:noctx
			case "windows":
				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
				cmd = exec.Command("cmd", "/c", "start", "", p) //nolint:noctx
			default:
				// Unsupported OS: nothing to do.
				return
			}
			if err := cmd.Start(); err != nil {
				log.Printf("failed to open file %s: %v", p, err)
			}
		}(filePath)

		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
	}
}

/*
detectInstalledVersion returns a best-effort installed version string.
Priority:
 1. If the build-in `version` variable is set to something other than "dev", return it.
 2. If Homebrew is present and reports a version for `matcha`, return that.
 3. If snap is present and lists `matcha`, return that.
 4. Fallback to the build `version` (likely "dev").
*/
func detectInstalledVersion() string {
	v := strings.TrimSpace(version)
	if v != "dev" && v != "" {
		return v
	}

	// Try Homebrew (macOS)
	if runtime.GOOS == goosDarwin {
		if _, err := exec.LookPath("brew"); err == nil {
			// `brew list --versions matcha` prints: matcha 1.2.3
			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil { //nolint:noctx
				parts := strings.Fields(string(out))
				if len(parts) >= 2 {
					return parts[1]
				}
			}
		}
	}

	// Try WinGet (Windows)
	if runtime.GOOS == "windows" {
		if _, err := exec.LookPath("winget"); err == nil {
			if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil { //nolint:noctx
				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
				for _, line := range lines {
					if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
						fields := strings.Fields(line)
						for _, f := range fields {
							if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
								return f
							}
						}
					}
				}
			}
		}
	}

	// Try snap (Linux)
	if runtime.GOOS == "linux" {
		if _, err := exec.LookPath("snap"); err == nil {
			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil { //nolint:noctx
				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
				if len(lines) >= 2 {
					fields := strings.Fields(lines[1])
					if len(fields) >= 2 {
						return fields[1]
					}
				}
			}
		}

		if _, err := exec.LookPath("flatpak"); err == nil {
			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil { //nolint:noctx
				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
				for _, line := range lines {
					line = strings.TrimSpace(line)
					if strings.HasPrefix(line, "Version:") {
						fields := strings.Fields(line)
						if len(fields) >= 2 {
							return fields[1]
						}
					}
				}
			}
		}
	}

	return v
}

/*
checkForUpdatesCmd queries GitHub for the latest release tag and returns a
tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
installed version. This runs in the background when the TUI initializes.
*/
func checkForUpdatesCmd() tea.Cmd {
	return func() tea.Msg {
		// Non-fatal: if anything goes wrong we just don't show the update message.
		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
		resp, err := httpClient.Get(api)
		if err != nil {
			return nil
		}
		defer resp.Body.Close() //nolint:errcheck

		var rel githubRelease
		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
			return nil
		}

		latest := strings.TrimPrefix(rel.TagName, "v")
		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
		if latest != "" && installed != "" && latest != installed {
			return UpdateAvailableMsg{Latest: latest, Current: installed}
		}
		return nil
	}
}

// runUpdateCLI implements the CLI entrypoint for `matcha update`.
// It detects the likely installation method and attempts the appropriate
// update path (Homebrew, Snap, or GitHub release binary extract).
// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
// Usage:
//
//	matcha oauth auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
//	matcha oauth token  <email>
//	matcha oauth revoke <email>
func runOAuthCLI(args []string) {
	if len(args) < 1 {
		fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Commands:")
		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize an email account via OAuth2 (opens browser)")
		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Flags for auth:")
		fmt.Fprintln(os.Stderr, "  --provider gmail|outlook  OAuth2 provider (auto-detected from email)")
		fmt.Fprintln(os.Stderr, "  --client-id ID            OAuth2 client ID")
		fmt.Fprintln(os.Stderr, "  --client-secret SECRET    OAuth2 client secret")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
		fmt.Fprintln(os.Stderr, "  Gmail:   ~/.config/matcha/oauth_client.json")
		fmt.Fprintln(os.Stderr, "  Outlook: ~/.config/matcha/oauth_client_outlook.json")
		exit(1)
	}

	// Find the Python script and pass through to it
	script, err := config.OAuthScriptPath()
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
		exit(1)
	}

	cmdArgs := append([]string{script}, args...)
	cmd := exec.Command("python3", cmdArgs...) //nolint:gosec,noctx
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) {
			exit(exitErr.ExitCode())
		}
		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
		exit(1)
	}
}

// stringSliceFlag implements flag.Value to allow repeated --attach flags.
type stringSliceFlag []string

func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
func (s *stringSliceFlag) Set(val string) error {
	*s = append(*s, val)
	return nil
}

// runSendCLI implements the CLI entrypoint for `matcha send`.
// It sends an email non-interactively using configured accounts.
func runSendCLI(args []string) {
	fs := flag.NewFlagSet("send", flag.ExitOnError)

	to := fs.String("to", "", "Recipient(s), comma-separated (required)")
	cc := fs.String("cc", "", "CC recipient(s), comma-separated")
	bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
	subject := fs.String("subject", "", "Email subject (required)")
	body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
	from := fs.String("from", "", "Sender account email (defaults to first configured account)")
	withSignature := fs.Bool("signature", true, "Append default signature")
	signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
	encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
	signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")

	var attachments stringSliceFlag
	fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")

	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Flags:")
		fs.PrintDefaults()
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Examples:")
		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
		fmt.Fprintln(os.Stderr, `  echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
	}

	if err := fs.Parse(args); err != nil {
		exit(1)
	}

	if *to == "" || *subject == "" {
		fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
		fs.Usage()
		exit(1)
	}

	// Read body from stdin if "-"
	emailBody := *body
	if emailBody == "-" {
		data, err := io.ReadAll(os.Stdin)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
			exit(1)
		}
		emailBody = string(data)
	}

	// Load config
	cfg, err := config.LoadConfig()
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
		exit(1)
	}
	if !cfg.HasAccounts() {
		fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
		exit(1)
	}

	// Resolve account
	var account *config.Account
	if *from != "" {
		account = cfg.GetAccountByEmail(*from)
		if account == nil {
			// Also try matching against FetchEmail
			for i := range cfg.Accounts {
				if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
					account = &cfg.Accounts[i]
					break
				}
			}
		}
		if account == nil {
			fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
			exit(1)
		}
	} else {
		account = cfg.GetFirstAccount()
	}

	// Use account S/MIME/PGP defaults unless explicitly set
	if !isFlagSet(fs, "sign-smime") {
		*signSMIME = account.SMIMESignByDefault
	}
	if !isFlagSet(fs, "sign-pgp") {
		*signPGP = account.PGPSignByDefault
	}

	// Append signature
	if *withSignature {
		if sig, err := config.LoadSignature(); err == nil && sig != "" {
			emailBody = emailBody + "\n\n" + sig
		}
	}

	// Process inline images (same logic as TUI sendEmail)
	images := make(map[string][]byte)
	re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
	matches := re.FindAllStringSubmatch(emailBody, -1)
	for _, match := range matches {
		imgPath := match[1]
		imgData, err := os.ReadFile(imgPath)
		if err != nil {
			log.Printf("Could not read image file %s: %v", imgPath, err)
			continue
		}
		cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
		images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
		emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
	}

	htmlBody := markdownToHTML([]byte(emailBody))

	// Process attachments
	attachMap := make(map[string][]byte)
	for _, attachPath := range attachments {
		fileData, err := os.ReadFile(attachPath)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
			exit(1)
		}
		attachMap[filepath.Base(attachPath)] = fileData
	}

	// Send
	recipients := splitEmails(*to)
	ccList := splitEmails(*cc)
	bccList := splitEmails(*bcc)

	rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
	if sendErr != nil {
		fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
		exit(1)
	}

	// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
	if account.ServiceProvider != "gmail" {
		if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
			log.Printf("Failed to append sent message to Sent folder: %v", err)
		}
	}

	fmt.Println("Email sent successfully.")
}

// isFlagSet returns true if the named flag was explicitly provided on the command line.
func isFlagSet(fs *flag.FlagSet, name string) bool {
	found := false
	fs.Visit(func(f *flag.Flag) {
		if f.Name == name {
			found = true
		}
	})
	return found
}

func runUpdateCLI() (err error) { //nolint:gocyclo
	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
	resp, err := httpClient.Get(api)
	if err != nil {
		return fmt.Errorf("could not query releases: %w", err)
	}
	defer resp.Body.Close() //nolint:errcheck

	var rel githubRelease
	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
		return fmt.Errorf("could not parse release info: %w", err)
	}

	latestTag := strings.TrimPrefix(rel.TagName, "v")

	fmt.Printf("Current version: %s\n", version)
	fmt.Printf("Latest version: %s\n", latestTag)

	// Quick check: if already up-to-date, exit
	cur := strings.TrimPrefix(version, "v")
	if latestTag == "" || cur == latestTag {
		fmt.Println("Already up to date.")
		return nil
	}

	// Detect Homebrew
	if _, err := exec.LookPath("brew"); err == nil {
		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")

		updateCmd := exec.Command("brew", "update") //nolint:noctx
		updateCmd.Stdout = os.Stdout
		updateCmd.Stderr = os.Stderr
		if err := updateCmd.Run(); err != nil {
			fmt.Printf("Homebrew update failed: %v\n", err)
			// continue to attempt upgrade even if update failed
		}

		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") //nolint:noctx
		upgradeCmd.Stdout = os.Stdout
		upgradeCmd.Stderr = os.Stderr
		if err := upgradeCmd.Run(); err == nil {
			fmt.Println("Successfully upgraded via Homebrew.")
			return nil
		}
		fmt.Printf("Homebrew upgrade failed: %v\n", err)
		// fallthrough to other methods
	}

	// Detect snap
	if _, err := exec.LookPath("snap"); err == nil {
		// Check if matcha is installed as a snap
		cmdCheck := exec.Command("snap", "list", "matcha") //nolint:noctx
		if err := cmdCheck.Run(); err == nil {
			fmt.Println("Detected Snap package — attempting to refresh.")
			cmd := exec.Command("snap", "refresh", "matcha") //nolint:noctx
			cmd.Stdout = os.Stdout
			cmd.Stderr = os.Stderr
			if err := cmd.Run(); err == nil {
				fmt.Println("Successfully refreshed snap.")
				return nil
			}
			fmt.Printf("Snap refresh failed: %v\n", err)
			// fallthrough
		}
	}
	// Detect flatpak
	if _, err := exec.LookPath("flatpak"); err == nil {
		// Check if matcha is installed as a flatpak
		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha") //nolint:noctx
		if err := cmdCheck.Run(); err == nil {
			fmt.Println("Detected Flatpak package — attempting to update.")
			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha") //nolint:noctx
			cmd.Stdout = os.Stdout
			cmd.Stderr = os.Stderr
			if err := cmd.Run(); err == nil {
				fmt.Println("Successfully updated flatpak.")
				return nil
			}
			fmt.Printf("Flatpak update failed: %v\n", err)
			// fallthrough
		}
	}

	// Detect WinGet
	if _, err := exec.LookPath("winget"); err == nil {
		cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
		if err := cmdCheck.Run(); err == nil {
			fmt.Println("Detected WinGet package — attempting to upgrade.")
			cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
			cmd.Stdout = os.Stdout
			cmd.Stderr = os.Stderr
			if err := cmd.Run(); err == nil {
				fmt.Println("Successfully upgraded via WinGet.")
				return nil
			}
			fmt.Printf("WinGet upgrade failed: %v\n", err)
			// fallthrough
		}
	}

	// Otherwise attempt to download the proper release asset and replace the binary.
	osName := runtime.GOOS
	arch := runtime.GOARCH

	// Try to find a matching asset
	var assetURL, assetName string
	for _, a := range rel.Assets {
		n := strings.ToLower(a.Name)
		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
			assetURL = a.BrowserDownloadURL
			assetName = a.Name
			break
		}
	}
	if assetURL == "" {
		// Try any asset that contains 'matcha' and os/arch as a fallback
		for _, a := range rel.Assets {
			n := strings.ToLower(a.Name)
			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
				assetURL = a.BrowserDownloadURL
				assetName = a.Name
				break
			}
		}
	}

	if assetURL == "" {
		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
	}

	fmt.Printf("Found release asset: %s\n", assetName)
	fmt.Println("Downloading...")

	// Download asset
	respAsset, err := httpClient.Get(assetURL)
	if err != nil {
		return fmt.Errorf("download failed: %w", err)
	}
	defer respAsset.Body.Close() //nolint:errcheck

	// Create a temp file for the download
	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
	if err != nil {
		return fmt.Errorf("could not create temp dir: %w", err)
	}
	defer os.RemoveAll(tmpDir) //nolint:errcheck

	assetPath := filepath.Join(tmpDir, assetName)
	outFile, err := os.Create(assetPath)
	if err != nil {
		return fmt.Errorf("could not create temp file: %w", err)
	}
	_, err = io.Copy(outFile, respAsset.Body)
	if err != nil {
		_ = outFile.Close()
		return fmt.Errorf("could not write asset to disk: %w", err)
	}
	if err := outFile.Close(); err != nil {
		return fmt.Errorf("could not finalize asset file: %w", err)
	}

	// Determine the expected binary name based on the OS.
	binaryName := "matcha"
	if runtime.GOOS == "windows" {
		binaryName = "matcha.exe"
	}

	// Extract the binary from the archive.
	var binPath string
	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { //nolint:gocritic
		f, err := os.Open(assetPath)
		if err != nil {
			return fmt.Errorf("could not open archive: %w", err)
		}
		defer f.Close() //nolint:errcheck
		gzr, err := gzip.NewReader(f)
		if err != nil {
			return fmt.Errorf("could not create gzip reader: %w", err)
		}
		tr := tar.NewReader(gzr)
		for {
			hdr, err := tr.Next()
			if err == io.EOF {
				break
			}
			if err != nil {
				return fmt.Errorf("error reading tar: %w", err)
			}
			name := filepath.Base(hdr.Name)
			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
				binPath = filepath.Join(tmpDir, binaryName)
				out, err := os.Create(binPath)
				if err != nil {
					return fmt.Errorf("could not create binary file: %w", err)
				}
				if _, err := io.Copy(out, tr); err != nil { //nolint:gosec
					_ = out.Close()
					return fmt.Errorf("could not extract binary: %w", err)
				}
				if err := out.Close(); err != nil {
					return fmt.Errorf("could not finalize extracted binary: %w", err)
				}
				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
					return fmt.Errorf("could not make binary executable: %w", err)
				}
				break
			}
		}
	} else if strings.HasSuffix(assetName, ".zip") {
		zr, err := zip.OpenReader(assetPath)
		if err != nil {
			return fmt.Errorf("could not open zip archive: %w", err)
		}
		defer zr.Close() //nolint:errcheck
		for _, zf := range zr.File {
			name := filepath.Base(zf.Name)
			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
				rc, err := zf.Open()
				if err != nil {
					return fmt.Errorf("could not open file in zip: %w", err)
				}
				binPath = filepath.Join(tmpDir, binaryName)
				out, err := os.Create(binPath)
				if err != nil {
					rc.Close() //nolint:errcheck,gosec
					return fmt.Errorf("could not create binary file: %w", err)
				}
				if _, err := io.Copy(out, rc); err != nil { //nolint:gosec
					_ = out.Close()
					_ = rc.Close()
					return fmt.Errorf("could not extract binary: %w", err)
				}
				if err := out.Close(); err != nil {
					_ = rc.Close()
					return fmt.Errorf("could not finalize extracted binary: %w", err)
				}
				if err := rc.Close(); err != nil {
					return fmt.Errorf("could not close zip entry: %w", err)
				}
				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
					return fmt.Errorf("could not make binary executable: %w", err)
				}
				break
			}
		}
	} else {
		// For non-archive assets, assume the asset is the binary itself.
		binPath = assetPath
		if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
			// ignore chmod errors but warn
			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
		}
	}

	if binPath == "" {
		return fmt.Errorf("could not locate matcha binary inside the release artifact")
	}

	// Replace the running executable with the new binary
	execPath, err := os.Executable()
	if err != nil {
		return fmt.Errorf("could not determine executable path: %w", err)
	}

	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
	execDir := filepath.Dir(execPath)
	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
	in, err := os.Open(binPath)
	if err != nil {
		return fmt.Errorf("could not open new binary: %w", err)
	}
	defer in.Close()                                                          //nolint:errcheck
	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) //nolint:gosec
	if err != nil {
		return fmt.Errorf("could not create temp binary in target dir: %w", err)
	}

	defer func() {
		cerr := out.Close()
		if err == nil && cerr != nil {
			err = fmt.Errorf("could not flush new binary to disk: %w", cerr)
		}
	}()

	if _, err = io.Copy(out, in); err != nil {
		return fmt.Errorf("could not write new binary to disk: %w", err)
	}

	// On Windows, a running executable cannot be overwritten directly.
	// Move the old binary out of the way first, then rename the new one in.
	if runtime.GOOS == "windows" {
		oldPath := execPath + ".old"
		_ = os.Remove(oldPath) // clean up any previous leftover
		if err := os.Rename(execPath, oldPath); err != nil {
			return fmt.Errorf("could not move old executable out of the way: %w", err)
		}
	}

	if err = os.Rename(tmpNew, execPath); err != nil {
		return fmt.Errorf("could not replace executable: %w", err)
	}

	fmt.Println("Successfully updated matcha to", latestTag)
	return nil
}

func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
	seen := make(map[uint32]struct{})
	for _, e := range existing {
		seen[e.UID] = struct{}{}
	}
	var unique []fetcher.Email
	for _, e := range incoming {
		if _, ok := seen[e.UID]; !ok {
			unique = append(unique, e)
		}
	}
	return unique
}

func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) {
	level := loglevel.LevelInfo
	showLogPanel := false
	if len(args) <= 1 {
		return args, level, showLogPanel
	}

	filtered := make([]string, 0, len(args))
	filtered = append(filtered, args[0])

	for i := 1; i < len(args); i++ {
		switch args[i] {
		case "--debug":
			level = loglevel.LevelDebug
		case "--verbose", "-V":
			if level < loglevel.LevelVerbose {
				level = loglevel.LevelVerbose
			}
		case "--logs":
			showLogPanel = true
		default:
			filtered = append(filtered, args[i:]...)
			return filtered, level, showLogPanel
		}
	}

	return filtered, level, showLogPanel
}

func exit(code int) {
	fetcher.CloseDebugFiles()
	os.Exit(code)
}

func main() { //nolint:gocyclo
	// termimage sandbox worker: if this process was spawned as a decode
	// worker (TERMIMAGE_WORKER=1), apply OS restrictions, decode, exit.
	// Must run before any other initialization.
	termimage.MaybeRunWorker()

	args, level, showLogPanel := parseGlobalFlags(os.Args)
	os.Args = args
	loglevel.Set(level)

	// If invoked with version flag, print version and exit
	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
		fmt.Printf("matcha version %s", version)
		if commit != "" {
			fmt.Printf(" (%s)", commit)
		}
		if date != "" {
			fmt.Printf(" built on %s", date)
		}
		fmt.Println()
		exit(0)
	}

	// If invoked as CLI update command, run updater and exit.
	if len(os.Args) > 1 && os.Args[1] == "update" {
		if err := runUpdateCLI(); err != nil {
			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
			exit(1)
		}
		exit(0)
	}

	// Daemon CLI subcommand: matcha daemon <start|stop|status|run>
	if len(os.Args) > 1 && os.Args[1] == "daemon" {
		runDaemonCLI(os.Args[2:])
		exit(0)
	}

	// OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
	// "gmail" is kept as an alias for backwards compatibility.
	if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
		runOAuthCLI(os.Args[2:])
		exit(0)
	}

	// Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
	if len(os.Args) > 1 && os.Args[1] == "send" {
		runSendCLI(os.Args[2:])
		exit(0)
	}

	// Install plugin CLI subcommand: matcha install <url_or_file>
	if len(os.Args) > 1 && os.Args[1] == "install" {
		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
			exit(1)
		}
		exit(0)
	}

	// Config CLI subcommand: matcha config [plugin_name]
	if len(os.Args) > 1 && os.Args[1] == "config" {
		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
			exit(1)
		}
		exit(0)
	}

	// Contacts CLI subcommand: matcha contacts <export|sync> [flags]
	if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 {
		switch os.Args[2] {
		case "export":
			if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
				fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
				exit(1)
			}
			exit(0)
		case "sync":
			if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil {
				fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err)
				exit(1)
			}
			exit(0)
		}
	}

	// Dict CLI subcommand: matcha dict <add|remove|list> [lang]
	if len(os.Args) > 1 && os.Args[1] == "dict" {
		if err := matchaCli.RunDict(os.Args[2:]); err != nil {
			fmt.Fprintf(os.Stderr, "dict: %v\n", err)
			os.Exit(1)
		}
		os.Exit(0)
	}

	// setup-mailto CLI subcommand: matcha setup-mailto
	if len(os.Args) > 1 && os.Args[1] == "setup-mailto" {
		if err := matchaCli.SetupMailto(); err != nil {
			fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err)
			exit(1)
		}
		exit(0)
	}

	// Marketplace TUI subcommand: matcha marketplace
	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
		mp := tui.NewMarketplace(true)
		p := tea.NewProgram(mp)
		if _, err := p.Run(); err != nil {
			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
			exit(1)
		}
		exit(0)
	}

	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
	if err := config.MigrateCacheFiles(); err != nil {
		log.Printf("warning: cache migration failed: %v", err)
	}

	// Initialize i18n
	if err := i18n.Init("en"); err != nil {
		log.Printf("Failed to initialize i18n: %v", err)
	}

	var mailtoURL *url.URL
	if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
		if u, err := url.Parse(os.Args[1]); err == nil {
			mailtoURL = u
		}
	}

	var initialModel *mainModel

	if config.IsSecureModeEnabled() {
		// Secure mode: show password prompt before loading config
		tui.RebuildStyles()
		initialModel = newInitialModel(nil, mailtoURL)
		initialModel.current = tui.NewPasswordPrompt()
	} else {
		cfg, err := config.LoadConfig()
		if err == nil {
			loglevel.Verbosef("matcha: loaded config with %d account(s)", len(cfg.GetAccountIDs()))
			if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
				log.Printf("warning: contacts migration failed: %v", migrateErr)
			}
			if cfg.Theme != "" {
				theme.SetTheme(cfg.Theme)
			}
			// Set language from config
			lang := i18n.DetectLanguage(cfg)
			if err := i18n.GetManager().SetLanguage(lang); err != nil {
				log.Printf("Failed to set language %s: %v", lang, err)
			}
		}
		tui.RebuildStyles()

		// Ensure PGP keys directory exists
		_ = config.EnsurePGPDir()

		if err != nil {
			initialModel = newInitialModel(nil, mailtoURL)
		} else {
			initialModel = newInitialModel(cfg, mailtoURL)
		}
	}

	if showLogPanel {
		logger := logging.NewBuffer(logging.DefaultMaxEntries)
		log.SetOutput(logger)
		initialModel.showLogPanel = true
		initialModel.logCh = logger.Subscribe()
		initialModel.logPanel = tui.NewLogPanel(logger)
	}

	// Initialize plugin system
	plugins := plugin.NewManager()
	plugins.LoadPlugins()
	if initialModel.config != nil {
		plugins.LoadSettingValues(initialModel.config.PluginSettings)
	}
	initialModel.plugins = plugins
	tui.BodyTransformer = func(body string, email fetcher.Email) string {
		folder := folderInbox
		if initialModel.folderInbox != nil {
			folder = initialModel.folderInbox.GetCurrentFolder()
		}
		t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder)
		return plugins.CallBodyRenderHook(t, body, email.Body)
	}
	plugins.CallHook(plugin.HookStartup)

	// Background sync macOS features
	if runtime.GOOS == goosDarwin {
		disableNotifications := false
		if initialModel.config != nil {
			disableNotifications = initialModel.config.DisableNotifications
		}
		if !disableNotifications {
			go func() {
				defer func() {
					if r := recover(); r != nil {
						log.Printf("panic in macOS sync goroutine: %v", r)
					}
				}()
				_ = config.SyncMacOSContacts()
				_ = theme.SyncWithMacOS()
			}()
		}
	}

	p := tea.NewProgram(initialModel)

	if _, err := p.Run(); err != nil {
		plugins.Close()
		fmt.Printf("Alas, there's been an error: %v", err)
		exit(1)
	}

	plugins.CallHook(plugin.HookShutdown)
	plugins.Close()
	fetcher.CloseDebugFiles()
}

func runDaemonCLI(args []string) {
	if len(args) == 0 {
		fmt.Println("Usage: matcha daemon <start|stop|status|run>")
		fmt.Println()
		fmt.Println("Commands:")
		fmt.Println("  start   Start the daemon in the background")
		fmt.Println("  stop    Stop the running daemon")
		fmt.Println("  status  Show daemon status")
		fmt.Println("  run     Run the daemon in the foreground")
		exit(1)
	}

	switch args[0] {
	case "start":
		runDaemonStart()
	case "stop":
		runDaemonStop()
	case "status":
		runDaemonStatus()
	case "run":
		runDaemonRun()
	default:
		fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
		exit(1)
	}
}

func runDaemonStart() {
	pidPath := daemonrpc.PIDPath()
	if pid, running := matchaDaemon.IsRunning(pidPath); running {
		fmt.Printf("Daemon already running (PID %d)\n", pid)
		return
	}

	// Fork ourselves with "daemon run".
	exe, err := os.Executable()
	if err != nil {
		fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
		exit(1)
	}

	cmd := exec.Command(exe, "daemon", "run") //nolint:noctx
	cmd.Stdout = nil
	cmd.Stderr = nil
	cmd.Stdin = nil

	// Detach from parent process.
	cmd.SysProcAttr = daemonclient.DaemonProcAttr()

	if err := cmd.Start(); err != nil {
		fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
		exit(1)
	}

	fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
}

func runDaemonStop() {
	pidPath := daemonrpc.PIDPath()
	pid, running := matchaDaemon.IsRunning(pidPath)
	if !running {
		fmt.Println("Daemon is not running")
		return
	}

	process, err := os.FindProcess(pid)
	if err != nil {
		fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
		exit(1)
	}

	if err := process.Signal(os.Interrupt); err != nil {
		fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
		exit(1)
	}

	fmt.Printf("Daemon stopped (PID %d)\n", pid)
}

func runDaemonStatus() {
	// Try connecting to daemon for live status.
	client, err := daemonclient.Dial()
	if err != nil {
		pidPath := daemonrpc.PIDPath()
		if pid, running := matchaDaemon.IsRunning(pidPath); running {
			fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
		} else {
			fmt.Println("Daemon is not running")
		}
		return
	}
	status, err := client.Status()
	client.Close() //nolint:errcheck,gosec
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
		exit(1)
	}

	fmt.Printf("Daemon running (PID %d)\n", status.PID)
	fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
	fmt.Printf("Accounts: %d\n", len(status.Accounts))
	for _, acct := range status.Accounts {
		fmt.Printf("  - %s\n", acct)
	}
}

func runDaemonRun() {
	cfg, err := config.LoadConfig()
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
		exit(1)
	}

	d := matchaDaemon.New(cfg)
	if err := d.Run(); err != nil {
		fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
		exit(1)
	}
}

func formatUptime(seconds int64) string {
	d := time.Duration(seconds) * time.Second
	if d < time.Minute {
		return fmt.Sprintf("%ds", int(d.Seconds()))
	}
	if d < time.Hour {
		return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
	}
	return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
