package main

import (
	"log"
	"os"
	"runtime"
	"strings"
	"sync"
	"time"

	"fyne.io/systray"
	"github.com/dustin/go-humanize"
	"github.com/robfig/cron/v3"
)

const (
	maxProfiles  = 16
	maxMenuWidth = 64
)

type prefixedMenuItem struct {
	*systray.MenuItem
	prefix string
}

func (p prefixedMenuItem) SetTitle(title string) {
	title = p.prefix + title
	if len(title) > maxMenuWidth {
		title = title[:maxMenuWidth-1] + "…"
	}
	p.MenuItem.SetTitle(title)
}

func setProfileFailed(key stateKey, status string) {
	state.mu.Lock()
	if status == "" {
		delete(state.failStatus, key)
	} else {
		state.failStatus[key] = status
	}
	state.mu.Unlock()
}

func getProfileFailStatus(key stateKey) string {
	state.mu.Lock()
	defer state.mu.Unlock()
	return state.failStatus[key]
}

func anyError() bool {
	state.mu.Lock()
	defer state.mu.Unlock()
	if len(state.failStatus) > 0 {
		return true
	}
	for _, ps := range state.profileStates {
		if ps.errMsg != "" || ps.repoErr != "" || ps.scheduleErr != "" {
			return true
		}
	}
	return false
}

func profilePrefix(cfg Config, prof Profile) string {
	if len(cfg.Profiles) > 1 {
		return prof.displayName() + " - "
	}
	return ""
}

func unreachableRecovered(cfg Config) bool {
	var unreachable []Profile
	for _, prof := range cfg.Profiles {
		if ps, ok := getProfileState(prof.profileKey()); ok && ps.errMsg == "" && ps.repoErr != "" {
			unreachable = append(unreachable, prof)
		}
	}
	for _, prof := range unreachable {
		if repoStatus(prof).errMsg == "" {
			return true
		}
	}
	return false
}

func startTrayBackendMonitor(selectedProfile func() Profile, applyConfig func(), item *systray.MenuItem, status prefixedMenuItem) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("backend update checker panicked: %v", r)
			}
		}()
		ticker := time.NewTicker(24 * time.Hour)
		defer ticker.Stop()
		for range ticker.C {
			prof := selectedProfile()
			backendPath, managed := findBackend(prof)
			if managed && !isAnyBusy() {
				cfg := loadConfig()
				checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), item, status, applyConfig)
			}
		}
	}()
}

func startTrayStatusMonitor(applyConfig func(), updateStatus, applyProfileUI func(Config)) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("status monitor panicked: %v", r)
			}
		}()
		prev := onBatteryPower()
		lastWall := time.Now().Round(0)
		ticker := time.NewTicker(time.Minute)
		defer ticker.Stop()
		for range ticker.C {
			now := time.Now().Round(0)
			drift := now.Sub(lastWall) - time.Minute
			lastWall = now

			if drift.Abs() > 30*time.Second && !isAnyBusy() {
				log.Print("clock jump detected, rebuilding scheduler")
				applyConfig()
				continue
			}

			cur := onBatteryPower()
			if cur != prev {
				prev = cur
				if !isAnyBusy() {
					cfg := loadConfig()
					updateStatus(cfg)
					applyProfileUI(cfg)
					continue
				}
			}

			cfg := loadConfig()
			if !isAnyBusy() && unreachableRecovered(cfg) {
				applyConfig()
				continue
			}
			updateStatus(cfg)
		}
	}()
}

func startAppUpdateChecker(item *systray.MenuItem) {
	go func() {
		check := func() {
			latest, err := latestVersion()
			if err != nil {
				log.Printf("update check failed: %v", err)
				return
			}
			if compareVersion(latest, version) > 0 && artifactExists(artifactURL(latest)) {
				latestKnownVersion.Store(&latest)
				item.SetTitle("Update to v" + latest)
			}
		}
		check()
		ticker := time.NewTicker(6 * time.Hour)
		defer ticker.Stop()
		for range ticker.C {
			check()
		}
	}()
}

type trayController struct {
	mGlobalStatus prefixedMenuItem
	mStatusItems  [maxProfiles]*systray.MenuItem
	mProfile      *systray.MenuItem
	profileItems  [maxProfiles]*systray.MenuItem
	mDownload     *systray.MenuItem
	mUpdate       *systray.MenuItem
	mSchedule     *systray.MenuItem
	mCancel       *systray.MenuItem
	mInit         *systray.MenuItem
	mRepo         *systray.MenuItem
	mBackup       *systray.MenuItem
	mPrune        *systray.MenuItem
	mCheck        *systray.MenuItem
	mUnlock       *systray.MenuItem
	mMount        *systray.MenuItem
	mConsole      *systray.MenuItem
	mPreHook      *systray.MenuItem
	mPostHook     *systray.MenuItem

	mFDA       *systray.MenuItem
	mWebEditor *systray.MenuItem
	mSettings  *systray.MenuItem
	mEnv       *systray.MenuItem
	mFixPerms  *systray.MenuItem
	mLog       *systray.MenuItem
	mFolder    *systray.MenuItem
	mAbout     *systray.MenuItem
	mQuit      *systray.MenuItem

	applyMu          sync.Mutex
	pendingFullApply bool
	schedulerMu      sync.RWMutex
	scheduler        *cron.Cron
}

func newTrayController() *trayController {
	c := &trayController{}
	applyIconMode(loadConfig().GUI.Icon)
	setIconAnimated("idle")
	systray.SetTooltip("Restray")

	c.mGlobalStatus = prefixedMenuItem{systray.AddMenuItem("", ""), ""}
	c.mGlobalStatus.Disable()
	c.mGlobalStatus.Hide()
	for i := range c.mStatusItems {
		c.mStatusItems[i] = systray.AddMenuItem("", "")
		c.mStatusItems[i].Disable()
		c.mStatusItems[i].Hide()
	}

	systray.AddSeparator()
	c.mProfile = systray.AddMenuItem("Profile", "")
	c.mProfile.Disable()
	for i := range c.profileItems {
		c.profileItems[i] = c.mProfile.AddSubMenuItem("", "")
		c.profileItems[i].Hide()
	}

	c.mDownload = systray.AddMenuItem("Install Backend", "Install selected backup backend")
	c.mDownload.Hide()
	c.mUpdate = systray.AddMenuItem("", "Update backend binary")
	c.mUpdate.Hide()
	c.mSchedule = systray.AddMenuItem("Run Schedule Now", "Run the full schedule immediately")
	c.mSchedule.Hide()
	c.mCancel = systray.AddMenuItem("Cancel Operation", "Cancel running operation")
	c.mCancel.Hide()
	c.mInit = systray.AddMenuItem("Initialize Repository", "Initialize a new backup repository")
	c.mInit.Hide()
	c.mRepo = systray.AddMenuItem("Operations", "Repository operations")
	c.mRepo.Disable()
	c.mBackup = c.mRepo.AddSubMenuItem("Backup", "Run a backup")
	c.mBackup.Disable()
	c.mPrune = c.mRepo.AddSubMenuItem("Prune", "Remove old snapshots and free space")
	c.mPrune.Disable()
	c.mCheck = c.mRepo.AddSubMenuItem("Check", "Verify repository integrity")
	c.mCheck.Disable()
	c.mUnlock = c.mRepo.AddSubMenuItem("Unlock", "Remove stale repository locks")
	c.mUnlock.Disable()
	c.mMount = c.mRepo.AddSubMenuItem("Mount", "Mount repository and browse snapshots")
	c.mConsole = c.mRepo.AddSubMenuItem("Shell", "Open terminal with repository environment")
	c.mPreHook = c.mRepo.AddSubMenuItem("Pre-Hook", "Run the pre-hook command")
	c.mPreHook.Disable()
	c.mPostHook = c.mRepo.AddSubMenuItem("Post-Hook", "Run the post-hook command")
	c.mPostHook.Disable()
	systray.AddSeparator()
	configure := systray.AddMenuItem("Configure", "Restray settings")
	c.mFDA = configure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access")
	c.mFDA.Hide()
	c.mWebEditor = configure.AddSubMenuItem("Open Web Editor", "Open the config/env web editor in your browser")
	c.mSettings = configure.AddSubMenuItem("Edit Config File", "Open config file in editor")
	c.mEnv = configure.AddSubMenuItem("Edit Env File", "Open env file in editor")
	c.mFixPerms = configure.AddSubMenuItem("Fix Permissions", "Config directory, config file, or env file permissions too open")
	c.mFixPerms.Hide()
	c.mLog = configure.AddSubMenuItem("View Log", "Open log file in editor")
	c.mFolder = configure.AddSubMenuItem("Open Folder", "Open folder in file manager")
	c.mAbout = configure.AddSubMenuItem("Restray v"+version, "Open repository in browser")
	systray.AddSeparator()
	c.mQuit = systray.AddMenuItem("Quit", "Quit Restray")
	return c
}

func onReady() {
	newTrayController().start(!fileExists(configPath()))
}

func (c *trayController) start(firstLaunch bool) {
	state.mu.Lock()
	state.onBusyChanged = c.onBusyChanged
	state.mu.Unlock()
	go c.applyConfig()
	if firstLaunch && needsFullDiskAccess() {
		go promptFullDiskAccess()
	}
	watchConfig(c.applyConfig)

	startTrayBackendMonitor(c.selectedProfile, c.applyConfig, c.mUpdate, c.mGlobalStatus)
	startTrayStatusMonitor(c.applyConfig, c.updateAllStatusItems, c.applyProfileUI)
	go c.handleClicks()

	if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().GUI.UpdatesEnabled() {
		startAppUpdateChecker(c.mAbout)
	}
	c.startProfileSelectionHandlers()
}

func (c *trayController) onBusyChanged(busy bool) {
	if busy {
		c.mCancel.Show()
		c.mSchedule.Hide()
		c.mInit.Hide()
		c.mBackup.Disable()
		c.mPrune.Disable()
		c.mCheck.Disable()
		c.mUnlock.Disable()
		c.mPreHook.Disable()
		c.mPostHook.Disable()
	}
}

func (c *trayController) stopScheduler() {
	c.schedulerMu.RLock()
	defer c.schedulerMu.RUnlock()
	if c.scheduler != nil {
		c.scheduler.Stop()
	}
}

func (c *trayController) formatNext(entryID cron.EntryID) string {
	c.schedulerMu.RLock()
	defer c.schedulerMu.RUnlock()
	if c.scheduler == nil || entryID == 0 {
		return ""
	}
	entry := c.scheduler.Entry(entryID)
	if entry.ID == 0 {
		return ""
	}
	return humanize.Time(entry.Schedule.Next(time.Now()))
}

func profileKeys(cfg Config) []stateKey {
	keys := make([]stateKey, 0, len(cfg.Profiles))
	for _, prof := range cfg.Profiles {
		keys = append(keys, prof.profileKey())
	}
	return keys
}

func (c *trayController) activeProfile(cfg Config) (int, Profile, bool) {
	if idx, prof, err := resolveProfileIndex(cfg, activeProfileKey()); err == nil {
		return idx, prof, true
	}
	if len(cfg.Profiles) == 0 || cfg.loadErr != nil {
		return 0, Profile{}, false
	}
	return 0, cfg.Profiles[0], true
}

func (c *trayController) selectedProfile() Profile {
	_, prof, ok := c.activeProfile(loadConfig())
	if ok {
		return prof
	}
	return Profile{}
}

func (c *trayController) setProfileTitle(cfg Config, idx int) {
	if idx < len(cfg.Profiles) {
		c.mProfile.SetTitle(cfg.Profiles[idx].displayName())
	}
	if len(cfg.Profiles) > 1 {
		c.mProfile.Enable()
	} else {
		c.mProfile.Disable()
	}
}

func (c *trayController) markActiveProfile(active, count int) {
	for i := 0; i < count && i < maxProfiles; i++ {
		if i == active {
			c.profileItems[i].Check()
			c.profileItems[i].Disable()
		} else {
			c.profileItems[i].Uncheck()
			c.profileItems[i].Enable()
		}
	}
}

func createOrEdit(m *systray.MenuItem, label, path string) {
	if _, err := os.Stat(path); err != nil {
		m.SetTitle("Create " + label)
	} else {
		m.SetTitle("Edit " + label)
	}
	m.Enable()
}

func (c *trayController) hideActions() {
	c.mSchedule.Hide()
	c.mInit.Hide()
	c.mDownload.Hide()
	c.mUpdate.Hide()
	c.mRepo.Disable()
}

func (c *trayController) doDownload() {
	setIconAnimated("download")
	if err := downloadBackend(c.selectedProfile(), c.mGlobalStatus); err != nil {
		setIconAnimated("fail")
		c.mGlobalStatus.SetTitle("Download failed: " + err.Error())
		c.mDownload.Show()
		return
	}
	c.applyConfig()
}

func (c *trayController) profileStatusText(cfg Config, idx int) string {
	if idx < 0 || idx >= len(cfg.Profiles) {
		return ""
	}
	prof := cfg.Profiles[idx]
	ps, ok := getProfileState(prof.profileKey())
	if !ok {
		return ""
	}
	prefix := profilePrefix(cfg, prof)

	switch {
	case ps.errMsg != "":
		return prefix + ps.errMsg
	case ps.scheduleErr != "":
		return prefix + ps.scheduleErr
	case ps.repoErr != "":
		return prefix + ps.repoErr
	default:
		paused := schedulePausedForBattery(prof)
		if prof.Schedule.Cron == "" {
			if cfg.GUI.ScheduleDisplay == "last" {
				return prefix + "Unscheduled, last " + formatLastBackup(getLastBackup(prof.profileKey()))
			}
			return prefix + "Unscheduled"
		}
		if paused {
			return prefix + "Paused (battery)"
		}
		next := c.formatNext(ps.entryID)
		switch cfg.GUI.ScheduleDisplay {
		case "none", "hidden":
			if next != "" {
				return prefix + next
			}
			return prefix + "Scheduled"
		case "cron":
			if next != "" {
				return prefix + next + " - " + prof.Schedule.Cron
			}
			return prefix + prof.Schedule.Cron
		case "last":
			last := formatLastBackup(getLastBackup(prof.profileKey()))
			if next != "" {
				return prefix + next + ", last " + last
			}
			return prefix + "last " + last
		default:
			schedule := describeCron(prof.Schedule.Cron)
			if next != "" {
				return prefix + next + " - " + schedule
			}
			return prefix + schedule
		}
	}
}

func (c *trayController) updateAllStatusItems(cfg Config) {
	c.mGlobalStatus.Hide()
	for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ {
		prof := cfg.Profiles[i]
		if !isProfileBusy(prof.profileKey()) {
			if failMsg := getProfileFailStatus(prof.profileKey()); failMsg != "" {
				prefixedMenuItem{c.mStatusItems[i], profilePrefix(cfg, prof)}.SetTitle(failMsg)
			} else {
				c.mStatusItems[i].SetTitle(c.profileStatusText(cfg, i))
			}
		}
		c.mStatusItems[i].Show()
	}
	for i := len(cfg.Profiles); i < maxProfiles; i++ {
		c.mStatusItems[i].Hide()
	}
}

func (c *trayController) applyProfileUI(cfg Config) {
	_, prof, ok := c.activeProfile(cfg)
	if !ok {
		return
	}
	ps, ok := getProfileState(prof.profileKey())
	if !ok {
		return
	}

	createOrEdit(c.mEnv, "Env File", prof.EnvFile)
	if envFilesInsecure(cfg.Profiles) {
		c.mFixPerms.Show()
	} else {
		c.mFixPerms.Hide()
	}

	c.mSchedule.Hide()
	c.mInit.Hide()
	c.mRepo.Disable()
	c.mPreHook.Disable()
	c.mPostHook.Disable()

	c.mMount.Show()
	if isProfileMounted(prof.profileKey()) {
		c.mMount.SetTitle("Unmount")
		c.mMount.Enable()
	} else if mountSupported(prof) {
		c.mMount.SetTitle("Mount")
		c.mMount.Enable()
	} else {
		c.mMount.SetTitle("Mount")
		c.mMount.Disable()
	}

	if isProfileBusy(prof.profileKey()) {
		c.mCancel.Show()
		c.mRepo.Enable()
		c.mBackup.Disable()
		c.mPrune.Disable()
		c.mCheck.Disable()
		c.mUnlock.Disable()
		return
	}

	c.mCancel.Hide()
	switch {
	case ps.errMsg != "":
	case ps.repoErr != "":
		if ps.needsInit {
			c.mInit.Show()
		}
	default:
		c.mRepo.Enable()
		if len(prof.Backup.Paths) > 0 {
			c.mBackup.Enable()
		} else {
			c.mBackup.Disable()
		}
		c.mPrune.Enable()
		c.mCheck.Enable()
		if prof.backend().supportsUnlock() {
			c.mUnlock.Enable()
		} else {
			c.mUnlock.Disable()
		}
		c.mSchedule.Show()
		if prof.scheduleDefinitionError() == "" {
			c.mSchedule.Enable()
		} else {
			c.mSchedule.Disable()
		}
		if prof.PreHook != "" {
			c.mPreHook.Enable()
		}
		if prof.PostHook != "" {
			c.mPostHook.Enable()
		}
	}
}

func (c *trayController) probeProfileState(cfg Config, prof Profile) profileState {
	var ps profileState
	if prof.Schedule.Cron != "" {
		ps.scheduleErr = prof.scheduleDefinitionError()
		if ps.scheduleErr == "" {
			if _, err := cron.ParseStandard(prof.Schedule.Cron); err != nil {
				ps.scheduleErr = "Invalid cron: " + prof.Schedule.Cron
			}
		}
	}
	if path, _ := findBackend(prof); path == "" {
		ps.errMsg = prof.backendDisplayName() + " not found"
		return ps
	}
	ps.errMsg = prof.repositoryError()
	if ps.errMsg == "" {
		wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.profileKey()).IsZero()
		var rs repoResult
		if wantsLast {
			var last time.Time
			rs, last = repoStatusAndLastBackup(prof)
			if !last.IsZero() {
				setLastBackup(prof.profileKey(), last)
			}
		} else {
			rs = repoStatus(prof)
		}
		ps.repoErr = rs.errMsg
		ps.needsInit = rs.needsInit
	}
	return ps
}

func (c *trayController) refreshProfile(key stateKey) {
	c.applyMu.Lock()
	cfg := loadConfig()
	idx, prof, err := resolveProfileIndex(cfg, key)
	if err != nil || idx >= maxProfiles {
		c.applyMu.Unlock()
		c.applyConfig()
		return
	}
	defer c.applyMu.Unlock()

	c.mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...")
	setProfileProbeState(key, c.probeProfileState(cfg, prof))
	if !isProfileBusy(key) {
		if failMsg := getProfileFailStatus(key); failMsg != "" {
			prefixedMenuItem{c.mStatusItems[idx], profilePrefix(cfg, prof)}.SetTitle(failMsg)
		} else {
			c.mStatusItems[idx].SetTitle(c.profileStatusText(cfg, idx))
		}
	}
	if !isAnyBusy() {
		if anyError() {
			setIconAnimated("fail")
		} else {
			setIconAnimated("idle")
		}
	}
	c.applyProfileUI(cfg)
}

func (c *trayController) finishProfile(key stateKey) {
	c.applyMu.Lock()
	pending := c.pendingFullApply
	c.pendingFullApply = false
	c.applyMu.Unlock()
	if pending {
		c.applyConfig()
		return
	}
	c.refreshProfile(key)
}

func (c *trayController) applyConfig() {
	c.applyMu.Lock()
	defer c.applyMu.Unlock()

	if isAnyBusy() {
		c.pendingFullApply = true
		cfg := loadConfig()
		if applyIconMode(cfg.GUI.Icon) {
			refreshIcon()
		}
		activeKey := resetProfileState(profileKeys(cfg))
		activeIdx, _, err := resolveProfileIndex(cfg, activeKey)
		if err != nil {
			activeIdx = 0
		}
		c.markActiveProfile(activeIdx, len(cfg.Profiles))
		c.setProfileTitle(cfg, activeIdx)
		c.updateAllStatusItems(cfg)
		c.applyProfileUI(cfg)
		return
	}

	c.pendingFullApply = false
	cfg := loadConfig()
	applyIconMode(cfg.GUI.Icon)
	if cfg.loadErr != nil {
		setIconAnimated("fail")
		c.mGlobalStatus.SetTitle(configLoadErrorMessage(cfg.loadErr))
		c.mGlobalStatus.Show()
		c.hideActions()
		c.mProfile.Disable()
		for i := range c.mStatusItems {
			c.mStatusItems[i].Hide()
			c.profileItems[i].Hide()
		}
		return
	}
	state.mu.Lock()
	state.notifications = strings.ToLower(cfg.GUI.Notifications)
	state.mu.Unlock()

	c.schedulerMu.Lock()
	oldScheduler := c.scheduler
	c.scheduler = nil
	c.schedulerMu.Unlock()
	if oldScheduler != nil {
		oldScheduler.Stop()
	}

	createOrEdit(c.mSettings, "Config File", configPath())
	c.mDownload.Hide()
	c.mUpdate.Hide()

	keys := profileKeys(cfg)
	activeKey := resetProfileState(keys)
	for _, key := range keys {
		setProfileCronEntry(key, 0)
	}
	activeIdx, prof, err := resolveProfileIndex(cfg, activeKey)
	if err != nil {
		activeIdx = 0
		prof = Profile{}
	}
	backendPath, managed := findBackend(prof)
	backendName := prof.backendDisplayName()

	if backendPath == "" && selfManagesBackend && cfg.GUI.BackendManagementEnabled() {
		c.mGlobalStatus.SetTitle("Downloading " + prof.backendName() + "...")
		c.mGlobalStatus.Show()
		go c.doDownload()
		return
	}
	if backendPath == "" {
		setIconAnimated("fail")
		c.mGlobalStatus.SetTitle(backendName + " not found")
		c.mGlobalStatus.Show()
		c.hideActions()
		c.mDownload.SetTitle("Install " + backendName)
		c.mDownload.Show()
		c.mProfile.Disable()
		return
	}

	if needsFullDiskAccess() {
		c.mFDA.Show()
	} else {
		c.mFDA.Hide()
	}

	nextScheduler := cron.New()
	nextScheduler.Start()
	c.schedulerMu.Lock()
	c.scheduler = nextScheduler
	c.schedulerMu.Unlock()

	c.markActiveProfile(activeIdx, len(cfg.Profiles))
	c.setProfileTitle(cfg, activeIdx)
	setIconAnimated("busy")

	var wg sync.WaitGroup
	for i, prof := range cfg.Profiles {
		label := prof.displayName()
		if i < maxProfiles {
			connecting := "Connecting..."
			if len(cfg.Profiles) > 1 {
				connecting = label + " - " + connecting
			}
			c.mStatusItems[i].SetTitle(connecting)
			c.mStatusItems[i].Show()
			c.profileItems[i].SetTitle(label)
			c.profileItems[i].Show()
		}
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.initializeProfile(cfg, nextScheduler, i, prof, activeKey)
		}()
	}
	wg.Wait()

	for i := len(cfg.Profiles); i < maxProfiles; i++ {
		c.profileItems[i].Hide()
		c.profileItems[i].Uncheck()
	}
	if anyError() {
		setIconAnimated("fail")
	} else {
		setIconAnimated("idle")
	}
	c.updateAllStatusItems(cfg)
	c.applyProfileUI(cfg)

	if managed {
		go checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), c.mUpdate, c.mGlobalStatus, c.applyConfig)
	}
}

func (c *trayController) initializeProfile(cfg Config, sched *cron.Cron, idx int, prof Profile, activeKey stateKey) {
	key := prof.profileKey()
	ps := c.probeProfileState(cfg, prof)
	if prof.Schedule.Cron != "" && idx < maxProfiles {
		if ps.scheduleErr != "" {
			log.Printf("[%s] skipping schedule: %s", prof.displayName(), ps.scheduleErr)
		} else {
			eid, err := sched.AddFunc(prof.Schedule.Cron, func() {
				runScheduleCallback(key, func() { c.finishProfile(key) }, func(currentCfg Config, currentIdx int, current Profile) {
					if currentIdx >= maxProfiles {
						return
					}
					item := prefixedMenuItem{c.mStatusItems[currentIdx], profilePrefix(currentCfg, current)}
					runScheduledAcquired(key, item, current)
				})
			})
			if err != nil {
				log.Printf("[%s] invalid cron expression %q: %v", prof.displayName(), prof.Schedule.Cron, err)
				ps.scheduleErr = "Invalid cron: " + prof.Schedule.Cron
			} else {
				setProfileCronEntry(key, eid)
			}
		}
	}
	setProfileProbeState(key, ps)
	if idx < maxProfiles {
		c.mStatusItems[idx].SetTitle(c.profileStatusText(cfg, idx))
	}
	if key == activeKey {
		c.applyProfileUI(cfg)
	}
}

func (c *trayController) selectedOperation() (Profile, prefixedMenuItem, bool) {
	cfg := loadConfig()
	idx, prof, ok := c.activeProfile(cfg)
	if !ok || idx >= maxProfiles {
		return Profile{}, prefixedMenuItem{}, false
	}
	return prof, prefixedMenuItem{c.mStatusItems[idx], profilePrefix(cfg, prof)}, true
}

func (c *trayController) repoOp(prof Profile, ms prefixedMenuItem, status string, args ...string) {
	key := prof.profileKey()
	go func() {
		if !acquireProfile(key) {
			return
		}
		setProfileFailed(key, "")
		defer c.finishProfile(key)
		defer releaseProfile(key)
		ms.SetTitle(status)
		if msg, err := runBackend(key, prof, ms, args...); err != nil {
			setProfileFailed(key, msg)
			notifyError(args[0], msg)
		} else {
			notifySuccess(args[0])
		}
	}()
}

func (c *trayController) runManualOperation(run func(stateKey, prefixedMenuItem, Profile, func())) {
	prof, item, ok := c.selectedOperation()
	if !ok {
		return
	}
	key := prof.profileKey()
	go run(key, item, prof, func() { c.finishProfile(key) })
}

func (c *trayController) handleClicks() {
	for {
		select {
		case <-c.mSchedule.ClickedCh:
			c.runManualOperation(runScheduled)
		case <-c.mBackup.ClickedCh:
			c.runManualOperation(runBackup)
		case <-c.mCancel.ClickedCh:
			cancelProfile(c.selectedProfile().profileKey())
		case <-c.mPrune.ClickedCh:
			if prof, item, ok := c.selectedOperation(); ok && len(prof.Prune.Args) > 0 {
				c.repoOp(prof, item, "Pruning repository...", forgetArgs(prof)...)
			}
		case <-c.mCheck.ClickedCh:
			if prof, item, ok := c.selectedOperation(); ok {
				c.repoOp(prof, item, "Checking repository...", checkArgs(prof)...)
			}
		case <-c.mUnlock.ClickedCh:
			if prof, item, ok := c.selectedOperation(); ok && prof.backend().supportsUnlock() {
				c.repoOp(prof, item, "Unlocking repository...", "unlock")
			}
		case <-c.mInit.ClickedCh:
			if prof, item, ok := c.selectedOperation(); ok {
				c.repoOp(prof, item, "Initializing repository...", "init")
			}
		case <-c.mMount.ClickedCh:
			prof := c.selectedProfile()
			key := prof.profileKey()
			if isProfileMounted(key) {
				stopProfileMount(key)
			} else {
				go startMount(key, prof, c.mMount, func() { c.finishProfile(key) })
			}
		case <-c.mConsole.ClickedCh:
			cfg := loadConfig()
			if _, prof, ok := c.activeProfile(cfg); ok {
				openConsole(prof, len(cfg.Profiles), cfg.GUI.Terminal, "")
			}
		case <-c.mPreHook.ClickedCh:
			prof := c.selectedProfile()
			c.runManualHook(prof, prof.PreHook, "Pre-hook")
		case <-c.mPostHook.ClickedCh:
			prof := c.selectedProfile()
			c.runManualHook(prof, prof.PostHook, "Post-hook")
		case <-c.mDownload.ClickedCh:
			if !selfManagesBackend {
				if c.selectedProfile().backend() == BackendRustic {
					openFile("https://rustic.cli.rs/docs/installation.html")
				} else {
					openFile("https://restic.readthedocs.io/en/stable/020_installation.html")
				}
			} else if !isAnyBusy() {
				c.hideActions()
				go c.doDownload()
			}
		case <-c.mUpdate.ClickedCh:
			if selfManagesBackend && !isAnyBusy() {
				c.hideActions()
				go c.doDownload()
			}
		case <-c.mSettings.ClickedCh:
			if err := ensureConfigFile(); err != nil {
				log.Printf("config: %v", err)
				continue
			}
			openInEditor(configPath())
		case <-c.mWebEditor.ClickedCh:
			if err := ensureConfigFile(); err != nil {
				log.Printf("config: %v", err)
				continue
			}
			url, err := ensureWebEditor()
			if err != nil {
				log.Printf("webeditor: %v", err)
				continue
			}
			openFile(url)
		case <-c.mEnv.ClickedCh:
			prof := c.selectedProfile()
			if err := ensureEnvFile(prof.EnvFile); err != nil {
				log.Printf("env: %v", err)
				continue
			}
			openInEditor(prof.EnvFile)
		case <-c.mFDA.ClickedCh:
			openFullDiskAccessSettings()
		case <-c.mFixPerms.ClickedCh:
			cfg := loadConfig()
			fixEnvFilePermissions(cfg.Profiles)
			if envFilesInsecure(cfg.Profiles) {
				c.mFixPerms.Show()
			} else {
				c.mFixPerms.Hide()
			}
			if !isAnyBusy() {
				c.applyConfig()
			}
		case <-c.mLog.ClickedCh:
			cfg := loadConfig()
			if _, prof, ok := c.activeProfile(cfg); ok {
				openConsole(prof, len(cfg.Profiles), cfg.GUI.Terminal, logPath())
			}
		case <-c.mFolder.ClickedCh:
			openFile(configDir())
		case <-c.mAbout.ClickedCh:
			if v := latestKnownVersion.Load(); v != nil {
				if applyUpdate(*v) {
					c.stopScheduler()
					stopAllMounts()
					systray.Quit()
				}
			} else {
				openFile("https://tangled.org/devins.page/restray")
			}
		case <-c.mQuit.ClickedCh:
			c.stopScheduler()
			stopAllMounts()
			systray.Quit()
		}
	}
}

func (c *trayController) runManualHook(prof Profile, hook, name string) {
	if hook == "" {
		return
	}
	go func() {
		if err := runHook(hook, prof); err != nil {
			notifyError(name, name+" failed")
		} else {
			notifySuccess(name)
		}
	}()
}

func (c *trayController) startProfileSelectionHandlers() {
	for i := range c.profileItems {
		idx := i
		go func() {
			for range c.profileItems[idx].ClickedCh {
				cfg := loadConfig()
				if idx >= len(cfg.Profiles) {
					continue
				}
				setActiveProfileKey(cfg.Profiles[idx].profileKey())
				c.markActiveProfile(idx, len(cfg.Profiles))
				c.setProfileTitle(cfg, idx)
				c.applyProfileUI(cfg)
			}
		}()
	}
}

func formatLastBackup(last time.Time) string {
	if last.IsZero() {
		return "never"
	}
	return humanize.Time(last)
}
