// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

//go:build cgo || !darwin

// Package systray provides a minimal Tailscale systray application.
package systray

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"os/signal"
	"runtime"
	"slices"
	"strings"
	"sync"
	"syscall"
	"time"

	"fyne.io/systray"
	"github.com/atotto/clipboard"
	dbus "github.com/godbus/dbus/v5"
	"github.com/toqueteos/webbrowser"
	"tailscale.com/client/local"
	"tailscale.com/ipn"
	"tailscale.com/ipn/ipnstate"
	"tailscale.com/tailcfg"
	"tailscale.com/util/slicesx"
	"tailscale.com/util/stringsx"
)

var (
	// newMenuDelay is the amount of time to sleep after creating a new menu,
	// but before adding items to it. This works around a bug in some dbus implementations.
	newMenuDelay time.Duration

	// if true, treat all mullvad exit node countries as single-city.
	// Instead of rendering a submenu with cities, just select the highest-priority peer.
	hideMullvadCities bool
)

// Run starts the systray menu and blocks until the menu exits.
func (menu *Menu) Run() {
	menu.updateState()

	// exit cleanly on SIGINT and SIGTERM
	go func() {
		interrupt := make(chan os.Signal, 1)
		signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
		select {
		case <-interrupt:
			menu.onExit()
		case <-menu.bgCtx.Done():
		}
	}()
	go menu.lc.IncrementCounter(menu.bgCtx, "systray_start", 1)

	systray.Run(menu.onReady, menu.onExit)
}

// Menu represents the systray menu, its items, and the current Tailscale state.
type Menu struct {
	mu sync.Mutex // protects the entire Menu

	lc          local.Client
	status      *ipnstate.Status
	curProfile  ipn.LoginProfile
	allProfiles []ipn.LoginProfile

	// readonly is whether the systray app is running in read-only mode.
	// This is set if LocalAPI returns a permission error,
	// typically because the user needs to run `tailscale set --operator=$USER`.
	readonly bool

	bgCtx    context.Context // ctx for background tasks not involving menu item clicks
	bgCancel context.CancelFunc

	// Top-level menu items
	connect    *systray.MenuItem
	disconnect *systray.MenuItem
	self       *systray.MenuItem
	exitNodes  *systray.MenuItem
	more       *systray.MenuItem
	quit       *systray.MenuItem

	rebuildCh  chan struct{} // triggers a menu rebuild
	accountsCh chan ipn.ProfileID
	exitNodeCh chan tailcfg.StableNodeID // ID of selected exit node

	eventCancel context.CancelFunc // cancel eventLoop

	notificationIcon *os.File // icon used for desktop notifications
}

func (menu *Menu) init() {
	if menu.bgCtx != nil {
		// already initialized
		return
	}

	menu.rebuildCh = make(chan struct{}, 1)
	menu.accountsCh = make(chan ipn.ProfileID)
	menu.exitNodeCh = make(chan tailcfg.StableNodeID)

	// dbus wants a file path for notification icons, so copy to a temp file.
	menu.notificationIcon, _ = os.CreateTemp("", "tailscale-systray.png")
	io.Copy(menu.notificationIcon, connected.renderWithBorder(3))

	menu.bgCtx, menu.bgCancel = context.WithCancel(context.Background())
	go menu.watchIPNBus()
}

func init() {
	if runtime.GOOS != "linux" {
		// so far, these tweaks are only needed on Linux
		return
	}

	desktop := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP"))
	switch desktop {
	case "gnome":
		// GNOME expands submenus downward in the main menu, rather than flyouts to the side.
		// Either as a result of that or another limitation, there seems to be a maximum depth of submenus.
		// Mullvad countries that have a city submenu are not being rendered, and so can't be selected.
		// Handle this by simply treating all mullvad countries as single-city and select the best peer.
		hideMullvadCities = true
	case "kde":
		// KDE doesn't need a delay, and actually won't render submenus
		// if we delay for more than about 400µs.
		newMenuDelay = 0
	default:
		// Add a slight delay to ensure the menu is created before adding items.
		//
		// Systray implementations that use libdbusmenu sometimes process messages out of order,
		// resulting in errors such as:
		//    (waybar:153009): LIBDBUSMENU-GTK-WARNING **: 18:07:11.551: Children but no menu, someone's been naughty with their 'children-display' property: 'submenu'
		//
		// See also: https://github.com/fyne-io/systray/issues/12
		newMenuDelay = 10 * time.Millisecond
	}
}

// onReady is called by the systray package when the menu is ready to be built.
func (menu *Menu) onReady() {
	log.Printf("starting")
	setAppIcon(disconnected)
	menu.rebuild()
}

// updateState updates the Menu state from the Tailscale local client.
func (menu *Menu) updateState() {
	menu.mu.Lock()
	defer menu.mu.Unlock()
	menu.init()

	menu.readonly = false

	var err error
	menu.status, err = menu.lc.Status(menu.bgCtx)
	if err != nil {
		log.Print(err)
	}
	menu.curProfile, menu.allProfiles, err = menu.lc.ProfileStatus(menu.bgCtx)
	if err != nil {
		if local.IsAccessDeniedError(err) {
			menu.readonly = true
		}
		log.Print(err)
	}
}

// rebuild the systray menu based on the current Tailscale state.
//
// We currently rebuild the entire menu because it is not easy to update the existing menu.
// You cannot iterate over the items in a menu, nor can you remove some items like separators.
// So for now we rebuild the whole thing, and can optimize this later if needed.
func (menu *Menu) rebuild() {
	menu.mu.Lock()
	defer menu.mu.Unlock()
	menu.init()

	if menu.eventCancel != nil {
		menu.eventCancel()
	}
	ctx := context.Background()
	ctx, menu.eventCancel = context.WithCancel(ctx)

	systray.ResetMenu()

	if menu.readonly {
		const readonlyMsg = "No permission to manage Tailscale.\nSee tailscale.com/s/cli-operator"
		m := systray.AddMenuItem(readonlyMsg, "")
		onClick(ctx, m, func(_ context.Context) {
			webbrowser.Open("https://tailscale.com/s/cli-operator")
		})
		systray.AddSeparator()
	}

	menu.connect = systray.AddMenuItem("Connect", "")
	menu.disconnect = systray.AddMenuItem("Disconnect", "")
	menu.disconnect.Hide()
	systray.AddSeparator()

	// delay to prevent race setting icon on first start
	time.Sleep(newMenuDelay)

	// Set systray menu icon and title.
	// Also adjust connect/disconnect menu items if needed.
	var backendState string
	if menu.status != nil {
		backendState = menu.status.BackendState
	}
	switch backendState {
	case ipn.Running.String():
		if menu.status.ExitNodeStatus != nil && !menu.status.ExitNodeStatus.ID.IsZero() {
			if menu.status.ExitNodeStatus.Online {
				setTooltip("Using exit node")
				setAppIcon(exitNodeOnline)
			} else {
				setTooltip("Exit node offline")
				setAppIcon(exitNodeOffline)
			}
		} else {
			setTooltip(fmt.Sprintf("Connected to %s", menu.status.CurrentTailnet.Name))
			setAppIcon(connected)
		}
		menu.connect.SetTitle("Connected")
		menu.connect.Disable()
		menu.disconnect.Show()
		menu.disconnect.Enable()
	case ipn.Starting.String():
		setTooltip("Connecting")
		setAppIcon(loading)
	default:
		setTooltip("Disconnected")
		setAppIcon(disconnected)
	}

	if menu.readonly {
		menu.connect.Disable()
		menu.disconnect.Disable()
	}

	account := "Account"
	if pt := profileTitle(menu.curProfile); pt != "" {
		account = pt
	}
	if !menu.readonly {
		accounts := systray.AddMenuItem(account, "")
		setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
		time.Sleep(newMenuDelay)
		for _, profile := range menu.allProfiles {
			title := profileTitle(profile)
			var item *systray.MenuItem
			if profile.ID == menu.curProfile.ID {
				item = accounts.AddSubMenuItemCheckbox(title, "", true)
			} else {
				item = accounts.AddSubMenuItem(title, "")
			}
			setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
			onClick(ctx, item, func(ctx context.Context) {
				select {
				case <-ctx.Done():
				case menu.accountsCh <- profile.ID:
				}
			})
		}
	}

	if menu.status != nil && menu.status.Self != nil && len(menu.status.Self.TailscaleIPs) > 0 {
		title := fmt.Sprintf("This Device: %s (%s)", menu.status.Self.HostName, menu.status.Self.TailscaleIPs[0])
		menu.self = systray.AddMenuItem(title, "")
	} else {
		menu.self = systray.AddMenuItem("This Device: not connected", "")
		menu.self.Disable()
	}
	systray.AddSeparator()

	if !menu.readonly {
		menu.rebuildExitNodeMenu(ctx)
	}

	if menu.status != nil {
		menu.more = systray.AddMenuItem("More settings", "")
		onClick(ctx, menu.more, func(_ context.Context) {
			webbrowser.Open("http://100.100.100.100/")
		})
	}

	menu.quit = systray.AddMenuItem("Quit", "Quit the app")
	menu.quit.Enable()

	go menu.eventLoop(ctx)
}

// profileTitle returns the title string for a profile menu item.
func profileTitle(profile ipn.LoginProfile) string {
	title := profile.Name
	if profile.NetworkProfile.DomainName != "" {
		if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
			// windows and mac don't support multi-line menu
			title += " (" + profile.NetworkProfile.DomainName + ")"
		} else {
			title += "\n" + profile.NetworkProfile.DomainName
		}
	}
	return title
}

var (
	cacheMu   sync.Mutex
	httpCache = map[string][]byte{} // URL => response body
)

// setRemoteIcon sets the icon for menu to the specified remote image.
// Remote images are fetched as needed and cached.
func setRemoteIcon(menu *systray.MenuItem, urlStr string) {
	if menu == nil || urlStr == "" {
		return
	}

	cacheMu.Lock()
	b, ok := httpCache[urlStr]
	if !ok {
		resp, err := http.Get(urlStr)
		if err == nil && resp.StatusCode == http.StatusOK {
			b, _ = io.ReadAll(resp.Body)
			httpCache[urlStr] = b
			resp.Body.Close()
		}
	}
	cacheMu.Unlock()

	if len(b) > 0 {
		menu.SetIcon(b)
	}
}

// setTooltip sets the tooltip text for the systray icon.
func setTooltip(text string) {
	if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
		systray.SetTooltip(text)
	} else {
		// on Linux, SetTitle actually sets the tooltip
		systray.SetTitle(text)
	}
}

// eventLoop is the main event loop for handling click events on menu items
// and responding to Tailscale state changes.
// This method does not return until ctx.Done is closed.
func (menu *Menu) eventLoop(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case <-menu.rebuildCh:
			menu.updateState()
			menu.rebuild()
		case <-menu.connect.ClickedCh:
			_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
				Prefs: ipn.Prefs{
					WantRunning: true,
				},
				WantRunningSet: true,
			})
			if err != nil {
				log.Printf("error connecting: %v", err)
			}

		case <-menu.disconnect.ClickedCh:
			_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
				Prefs: ipn.Prefs{
					WantRunning: false,
				},
				WantRunningSet: true,
			})
			if err != nil {
				log.Printf("error disconnecting: %v", err)
			}

		case <-menu.self.ClickedCh:
			menu.copyTailscaleIP(menu.status.Self)

		case id := <-menu.accountsCh:
			if err := menu.lc.SwitchProfile(ctx, id); err != nil {
				log.Printf("error switching to profile ID %v: %v", id, err)
			}

		case exitNode := <-menu.exitNodeCh:
			if exitNode.IsZero() {
				log.Print("disable exit node")
				if err := menu.lc.SetUseExitNode(ctx, false); err != nil {
					log.Printf("error disabling exit node: %v", err)
				}
			} else {
				log.Printf("enable exit node: %v", exitNode)
				mp := &ipn.MaskedPrefs{
					Prefs: ipn.Prefs{
						ExitNodeID: exitNode,
					},
					ExitNodeIDSet: true,
				}
				if _, err := menu.lc.EditPrefs(ctx, mp); err != nil {
					log.Printf("error setting exit node: %v", err)
				}
			}

		case <-menu.quit.ClickedCh:
			systray.Quit()
		}
	}
}

// onClick registers a click handler for a menu item.
func onClick(ctx context.Context, item *systray.MenuItem, fn func(ctx context.Context)) {
	go func() {
		for {
			select {
			case <-ctx.Done():
				return
			case <-item.ClickedCh:
				fn(ctx)
			}
		}
	}()
}

// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState.
// This method does not return.
func (menu *Menu) watchIPNBus() {
	for {
		if err := menu.watchIPNBusInner(); err != nil {
			log.Println(err)
			if errors.Is(err, context.Canceled) {
				// If the context got canceled, we will never be able to
				// reconnect to IPN bus, so exit the process.
				log.Fatalf("watchIPNBus: %v", err)
			}
		}
		// If our watch connection breaks, wait a bit before reconnecting. No
		// reason to spam the logs if e.g. tailscaled is restarting or goes
		// down.
		time.Sleep(3 * time.Second)
	}
}

func (menu *Menu) watchIPNBusInner() error {
	watcher, err := menu.lc.WatchIPNBus(menu.bgCtx, ipn.NotifyNoPrivateKeys)
	if err != nil {
		return fmt.Errorf("watching ipn bus: %w", err)
	}
	defer watcher.Close()
	for {
		select {
		case <-menu.bgCtx.Done():
			return nil
		default:
			n, err := watcher.Next()
			if err != nil {
				return fmt.Errorf("ipnbus error: %w", err)
			}
			var rebuild bool
			if n.State != nil {
				log.Printf("new state: %v", n.State)
				rebuild = true
			}
			if n.Prefs != nil {
				rebuild = true
			}
			if rebuild {
				menu.rebuildCh <- struct{}{}
			}
		}
	}
}

// copyTailscaleIP copies the first Tailscale IP of the given device to the clipboard
// and sends a notification with the copied value.
func (menu *Menu) copyTailscaleIP(device *ipnstate.PeerStatus) {
	if device == nil || len(device.TailscaleIPs) == 0 {
		return
	}
	name := strings.Split(device.DNSName, ".")[0]
	ip := device.TailscaleIPs[0].String()
	err := clipboard.WriteAll(ip)
	if err != nil {
		log.Printf("clipboard error: %v", err)
	}

	menu.sendNotification(fmt.Sprintf("Copied Address for %v", name), ip)
}

// sendNotification sends a desktop notification with the given title and content.
func (menu *Menu) sendNotification(title, content string) {
	conn, err := dbus.SessionBus()
	if err != nil {
		log.Printf("dbus: %v", err)
		return
	}
	timeout := 3 * time.Second
	obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
	call := obj.Call("org.freedesktop.Notifications.Notify", 0, "Tailscale", uint32(0),
		menu.notificationIcon.Name(), title, content, []string{}, map[string]dbus.Variant{}, int32(timeout.Milliseconds()))
	if call.Err != nil {
		log.Printf("dbus: %v", call.Err)
	}
}

func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
	if menu.status == nil {
		return
	}

	status := menu.status
	menu.exitNodes = systray.AddMenuItem("Exit Nodes", "")
	time.Sleep(newMenuDelay)

	// register a click handler for a menu item to set nodeID as the exit node.
	setExitNodeOnClick := func(item *systray.MenuItem, nodeID tailcfg.StableNodeID) {
		onClick(ctx, item, func(ctx context.Context) {
			select {
			case <-ctx.Done():
			case menu.exitNodeCh <- nodeID:
			}
		})
	}

	noExitNodeMenu := menu.exitNodes.AddSubMenuItemCheckbox("None", "", status.ExitNodeStatus == nil)
	setExitNodeOnClick(noExitNodeMenu, "")

	// Show recommended exit node if available.
	if status.Self.CapMap.Contains(tailcfg.NodeAttrSuggestExitNodeUI) {
		sugg, err := menu.lc.SuggestExitNode(ctx)
		if err == nil {
			title := "Recommended: "
			if loc := sugg.Location; loc.Valid() && loc.Country() != "" {
				flag := countryFlag(loc.CountryCode())
				title += fmt.Sprintf("%s %s: %s", flag, loc.Country(), loc.City())
			} else {
				title += strings.Split(sugg.Name, ".")[0]
			}
			menu.exitNodes.AddSeparator()
			rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
			setExitNodeOnClick(rm, sugg.ID)
			if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
				rm.Check()
			}
		}
	}

	// Add tailnet exit nodes if present.
	var tailnetExitNodes []*ipnstate.PeerStatus
	for _, ps := range status.Peer {
		if ps.ExitNodeOption && ps.Location == nil {
			tailnetExitNodes = append(tailnetExitNodes, ps)
		}
	}
	if len(tailnetExitNodes) > 0 {
		menu.exitNodes.AddSeparator()
		menu.exitNodes.AddSubMenuItem("Tailnet Exit Nodes", "").Disable()
		for _, ps := range status.Peer {
			if !ps.ExitNodeOption || ps.Location != nil {
				continue
			}
			name := strings.Split(ps.DNSName, ".")[0]
			if !ps.Online {
				name += " (offline)"
			}
			sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
			if !ps.Online {
				sm.Disable()
			}
			if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
				sm.Check()
			}
			setExitNodeOnClick(sm, ps.ID)
		}
	}

	// Add mullvad exit nodes if present.
	var mullvadExitNodes mullvadPeers
	if status.Self.CapMap.Contains("mullvad") {
		mullvadExitNodes = newMullvadPeers(status)
	}
	if len(mullvadExitNodes.countries) > 0 {
		menu.exitNodes.AddSeparator()
		menu.exitNodes.AddSubMenuItem("Location-based Exit Nodes", "").Disable()
		mullvadMenu := menu.exitNodes.AddSubMenuItemCheckbox("Mullvad VPN", "", false)

		for _, country := range mullvadExitNodes.sortedCountries() {
			flag := countryFlag(country.code)
			countryMenu := mullvadMenu.AddSubMenuItemCheckbox(flag+" "+country.name, "", false)

			// single-city country, no submenu
			if len(country.cities) == 1 || hideMullvadCities {
				setExitNodeOnClick(countryMenu, country.best.ID)
				if status.ExitNodeStatus != nil {
					for _, city := range country.cities {
						for _, ps := range city.peers {
							if status.ExitNodeStatus.ID == ps.ID {
								mullvadMenu.Check()
								countryMenu.Check()
							}
						}
					}
				}
				continue
			}

			// multi-city country, build submenu with "best available" option and cities.
			time.Sleep(newMenuDelay)
			bm := countryMenu.AddSubMenuItemCheckbox("Best Available", "", false)
			setExitNodeOnClick(bm, country.best.ID)
			countryMenu.AddSeparator()

			for _, city := range country.sortedCities() {
				cityMenu := countryMenu.AddSubMenuItemCheckbox(city.name, "", false)
				setExitNodeOnClick(cityMenu, city.best.ID)
				if status.ExitNodeStatus != nil {
					for _, ps := range city.peers {
						if status.ExitNodeStatus.ID == ps.ID {
							mullvadMenu.Check()
							countryMenu.Check()
							cityMenu.Check()
						}
					}
				}
			}
		}
	}

	// TODO: "Allow Local Network Access" and "Run Exit Node" menu items
}

// mullvadPeers contains all mullvad peer nodes, sorted by country and city.
type mullvadPeers struct {
	countries map[string]*mvCountry // country code (uppercase) => country
}

// sortedCountries returns countries containing mullvad nodes, sorted by name.
func (mp mullvadPeers) sortedCountries() []*mvCountry {
	countries := slicesx.MapValues(mp.countries)
	slices.SortFunc(countries, func(a, b *mvCountry) int {
		return stringsx.CompareFold(a.name, b.name)
	})
	return countries
}

type mvCountry struct {
	code   string
	name   string
	best   *ipnstate.PeerStatus // highest priority peer in the country
	cities map[string]*mvCity   // city code => city
}

// sortedCities returns cities containing mullvad nodes, sorted by name.
func (mc *mvCountry) sortedCities() []*mvCity {
	cities := slicesx.MapValues(mc.cities)
	slices.SortFunc(cities, func(a, b *mvCity) int {
		return stringsx.CompareFold(a.name, b.name)
	})
	return cities
}

// countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag.
// It returns the empty string on error.
func countryFlag(code string) string {
	if len(code) != 2 {
		return ""
	}
	runes := make([]rune, 0, 2)
	for i := range 2 {
		b := code[i] | 32 // lowercase
		if b < 'a' || b > 'z' {
			return ""
		}
		// https://en.wikipedia.org/wiki/Regional_indicator_symbol
		runes = append(runes, 0x1F1E6+rune(b-'a'))
	}
	return string(runes)
}

type mvCity struct {
	name  string
	best  *ipnstate.PeerStatus // highest priority peer in the city
	peers []*ipnstate.PeerStatus
}

func newMullvadPeers(status *ipnstate.Status) mullvadPeers {
	countries := make(map[string]*mvCountry)
	for _, ps := range status.Peer {
		if !ps.ExitNodeOption || ps.Location == nil {
			continue
		}
		loc := ps.Location
		country, ok := countries[loc.CountryCode]
		if !ok {
			country = &mvCountry{
				code:   loc.CountryCode,
				name:   loc.Country,
				cities: make(map[string]*mvCity),
			}
			countries[loc.CountryCode] = country
		}
		city, ok := countries[loc.CountryCode].cities[loc.CityCode]
		if !ok {
			city = &mvCity{
				name: loc.City,
			}
			countries[loc.CountryCode].cities[loc.CityCode] = city
		}
		city.peers = append(city.peers, ps)
		if city.best == nil || ps.Location.Priority > city.best.Location.Priority {
			city.best = ps
		}
		if country.best == nil || ps.Location.Priority > country.best.Location.Priority {
			country.best = ps
		}
	}
	return mullvadPeers{countries}
}

// onExit is called by the systray package when the menu is exiting.
func (menu *Menu) onExit() {
	log.Printf("exiting")
	if menu.bgCancel != nil {
		menu.bgCancel()
	}
	if menu.eventCancel != nil {
		menu.eventCancel()
	}

	os.Remove(menu.notificationIcon.Name())
}