package main

import (
	"context"
	"crypto/md5"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"time"

	log "github.com/sirupsen/logrus"

	"github.com/gotk3/gotk3/gdk"
	"github.com/gotk3/gotk3/glib"
	"github.com/gotk3/gotk3/gtk"
	"github.com/joshuarubin/go-sway"
)

var descendants []sway.Node

type task struct {
	conID int64
	ID    string // will be created out of app_id or window class
	Name  string
	PID   uint32
	WsNum int64
}

func taskInstances(ID string, tasks []task) []task {
	var found []task
	for _, t := range tasks {
		if strings.Contains(strings.ToUpper(t.ID), strings.ToUpper(ID)) {
			found = append(found, t)
		}
	}
	return found
}

type TaskChange struct {
	Change sway.WindowEventChange
	Task   *task
}

type swayEventHandler struct {
	taskUpdateChannel      chan TaskChange
	workspaceUpdateChannel chan int64
}

func (t swayEventHandler) Workspace(ctx context.Context, event sway.WorkspaceEvent) {
	if event.Change == "focus" {
		// TODO: sway.WorkspaceEvent.Current should contain a Workspace, but contains Node,
		// this may be an error of the used library ...
		t.workspaceUpdateChannel <- 0
	}
}
func (t swayEventHandler) Mode(ctx context.Context, event sway.ModeEvent)                       {}
func (t swayEventHandler) BarConfigUpdate(ctx context.Context, event sway.BarConfigUpdateEvent) {}
func (t swayEventHandler) Binding(ctx context.Context, event sway.BindingEvent)                 {}
func (t swayEventHandler) Shutdown(ctx context.Context, event sway.ShutdownEvent)               {}
func (t swayEventHandler) Tick(ctx context.Context, event sway.TickEvent)                       {}
func (t swayEventHandler) BarStateUpdate(ctx context.Context, event sway.BarStateUpdateEvent)   {}
func (t swayEventHandler) BarStatusUpdate(ctx context.Context, event sway.BarStateUpdateEvent)  {}
func (t swayEventHandler) Input(ctx context.Context, event sway.InputEvent)                     {}
func (t swayEventHandler) Window(ctx context.Context, window sway.WindowEvent) {
	if window.Change == "new" || window.Change == "close" {
		t.taskUpdateChannel <- TaskChange{
			Change: window.Change,
			// TODO: gather enough details form sway.WindowEvent to create the task
			// structure and pass it on for smarter modifying the task array
			Task: nil,
		}
	}
}

// TODO: The channel should *not* return a []task, but rather a TaskChange event which should
// be used to modify the list in the frontend ...
func getTaskChangesChannel(ctx context.Context) (chan []task, error) {
	taskArrayChannel := make(chan []task, 1)
	eventHandler := swayEventHandler{
		taskUpdateChannel: make(chan TaskChange, 1),
	}

	go func() {
		// Blocks execution until we cancel the context
		if err := sway.Subscribe(ctx, eventHandler, sway.EventTypeWindow); err != nil {
			log.Fatal("Unable to subscribe to sway event:", err)
		}
	}()

	// Pretty hacky, but is simply used to convert a TaskChange to a task struct
	go func() {
		for {
			<-eventHandler.taskUpdateChannel
			tasks, err := listTasks()
			if err != nil {
				log.Errorf("Unable to process tasks from sway: %s", err.Error())
				return
			}

			taskArrayChannel <- tasks
		}
	}()

	return taskArrayChannel, nil
}

func getWorkspaceChangesChannel(ctx context.Context) chan int64 {
	workspaceUpdateChannel := make(chan int64, 1)
	eventHandler := swayEventHandler{
		workspaceUpdateChannel: make(chan int64, 1),
	}

	go func() {
		// Blocks execution until we cancel the context
		if err := sway.Subscribe(ctx, eventHandler, sway.EventTypeWorkspace); err != nil {
			log.Fatal("Unable to subscribe to sway event:", err)
		}
	}()

	go func() {
		ipc, _ := sway.New(ctx)

		for {
			<-eventHandler.workspaceUpdateChannel
			workspaces, _ := ipc.GetWorkspaces(ctx)

			for _, workspace := range workspaces {
				if workspace.Focused {
					workspaceUpdateChannel <- workspace.Num
					break
				}
			}
		}
	}()

	return workspaceUpdateChannel
}

// list sway tree, return tasks sorted by workspace numbers
func listTasks() ([]task, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	client, err := sway.New(ctx)
	if err != nil {
		return nil, err
	}

	tree, err := client.GetTree(ctx)
	if err != nil {
		return nil, err
	}

	workspaces, _ := client.GetWorkspaces(ctx)
	if err != nil {
		return nil, err
	}

	// In order not to add a separate function, let's set the global currentWsNum variable we need here
	for _, ws := range workspaces {
		if ws.Focused {
			currentWsNum = ws.Num
			break
		}
	}

	// all nodes in the tree
	nodes := tree.Nodes

	// find outputs in all nodes
	var outputs []*sway.Node
	for _, n := range nodes {
		if n.Type == "output" && !strings.HasPrefix(n.Name, "__") {
			outputs = append(outputs, n)
		}
	}

	// find workspaces in outputs
	var workspaceNodes []*sway.Node
	for _, o := range outputs {
		nodes = o.Nodes
		for _, n := range nodes {
			if n.Type == "workspace" {
				workspaceNodes = append(workspaceNodes, n)
			}
		}
	}

	var tasks []task
	// find cons in workspaces recursively
	for _, w := range workspaceNodes {
		wsNum := workspaceNum(workspaces, w.Name)
		descendants = nil
		for _, con := range w.Nodes {
			findDescendants(*con)
		}

		// create tasks from cons which represent tasks
		for _, con := range descendants {
			t, err := createTask(con, wsNum)
			if err == nil {
				tasks = append(tasks, *t)
			} else {
				log.Warn(err)
			}
		}

		fNodes := w.FloatingNodes
		for _, con := range fNodes {
			t, err := createTask(*con, wsNum)
			if err == nil {
				tasks = append(tasks, *t)
			} else {
				log.Warn(err)
			}
		}
	}
	sort.Slice(tasks, func(i int, j int) bool {
		return tasks[i].WsNum < tasks[j].WsNum
	})
	return tasks, nil
}

func findDescendants(con sway.Node) {
	if len(con.Nodes) > 0 {
		for _, node := range con.Nodes {
			findDescendants(*node)
		}
	} else {
		descendants = append(descendants, con)
	}
}

func createTask(con sway.Node, wsNum int64) (*task, error) {
	t := &task{}
	t.conID = con.ID
	if con.AppID != nil {
		t.ID = *con.AppID
	} else if con.WindowProperties != nil {
		wp := *con.WindowProperties
		t.ID = wp.Class
	} else {
		return nil, errors.New("damaged Node data received, task skipped")
	}
	t.Name = con.Name
	if con.PID != nil {
		t.PID = *con.PID
	} else {
		return nil, errors.New("damaged Node data received, task skipped")
	}

	t.WsNum = wsNum

	return t, nil
}

func workspaceNum(workspaces []sway.Workspace, name string) int64 {
	for _, ws := range workspaces {
		if ws.Name == name {
			return ws.Num
		}
	}
	return 0
}

func pinnedButton(ID string) *gtk.Box {
	box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
	button, _ := gtk.ButtonNew()
	box.PackStart(button, false, false, 0)

	image, err := createImage(ID, imgSizeScaled)
	if err != nil {
		pixbuf, err := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/icon-missing.svg"),
			imgSizeScaled, imgSizeScaled)
		if err == nil {
			image, _ = gtk.ImageNewFromPixbuf(pixbuf)
		} else {
			image, _ = gtk.ImageNew()
		}
	}

	if image != nil {
		button.SetImage(image)
		button.SetImagePosition(gtk.POS_TOP)
		button.SetAlwaysShowImage(true)
	}
	button.SetTooltipText(getName(ID))
	pixbuf, _ := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/task-empty.svg"),
		imgSizeScaled, imgSizeScaled/8)
	img, _ := gtk.ImageNewFromPixbuf(pixbuf)
	box.PackStart(img, false, false, 0)

	button.Connect("clicked", func() {
		launch(ID)
	})

	button.Connect("button-release-event", func(btn *gtk.Button, e *gdk.Event) bool {
		btnEvent := gdk.EventButtonNewFromEvent(e)
		if btnEvent.Button() == 1 {
			launch(ID)
			return true
		} else if btnEvent.Button() == 3 {
			contextMenu := pinnedMenuContext(ID)
			contextMenu.PopupAtWidget(button, widgetAnchor, menuAnchor, nil)
			return true
		}
		return false
	})

	button.Connect("enter-notify-event", cancelClose)
	return box
}

func pinnedMenuContext(taskID string) gtk.Menu {
	menu, _ := gtk.MenuNew()
	menuItem, _ := gtk.MenuItemNewWithLabel("Unpin")
	menuItem.Connect("activate", func() {
		unpinTask(taskID)
	})
	menu.Append(menuItem)

	menu.ShowAll()
	return *menu
}

func launcherButton() *gtk.Button {
	if !*noLauncher && *launcherCmd != "" {
		button, _ := gtk.ButtonNew()
		pixbuf, err := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/grid.svg"), imgSizeScaled, imgSizeScaled)
		if err == nil {
			image, _ := gtk.ImageNewFromPixbuf(pixbuf)
			button.SetImage(image)
			button.SetAlwaysShowImage(true)

			button.Connect("clicked", func() {
				elements := strings.Split(*launcherCmd, " ")
				cmd := exec.Command(elements[0], elements[1:]...)

				go func() {
					err := cmd.Run()
					if err != nil {
						log.Warnf("Unable to start program: %s", err.Error())
					}
				}()

				if *autohide {
					win.Hide()
				}
			})
			button.Connect("enter-notify-event", cancelClose)
		} else {
			log.Errorf("Unable to show grid button: %s", err.Error())
		}
		return button
	}
	return nil
}

/*
Window on-leave-notify event hides the dock with glib Timeout 1000 ms.
We might have left the window by accident, so let's clear the timeout if window re-entered.
Furthermore - hovering a button triggers window on-leave-notify event, and the timeout
needs to be cleared as well.
*/
func cancelClose() {
	if src > 0 {
		glib.SourceRemove(src)
		src = 0
	}
}

func taskButton(t task, instances []task) *gtk.Box {
	box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
	button, _ := gtk.ButtonNew()
	box.PackStart(button, false, false, 0)

	image, err := createImage(t.ID, imgSizeScaled)
	if err != nil {
		pixbuf, err := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/icon-missing.svg"),
			imgSizeScaled, imgSizeScaled)
		if err == nil {
			image, _ = gtk.ImageNewFromPixbuf(pixbuf)
		} else {
			image, _ = gtk.ImageNew()
		}
	}

	if image != nil {
		button.SetImage(image)
		button.SetImagePosition(gtk.POS_TOP)
		button.SetAlwaysShowImage(true)
	}
	button.SetTooltipText(getName(t.ID))
	var img *gtk.Image
	if len(instances) < 2 {
		pixbuf, _ := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/task-single.svg"),
			imgSizeScaled, imgSizeScaled/8)
		img, _ = gtk.ImageNewFromPixbuf(pixbuf)
	} else {
		pixbuf, _ := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/task-multiple.svg"),
			imgSizeScaled, imgSizeScaled/8)
		img, _ = gtk.ImageNewFromPixbuf(pixbuf)
	}
	box.PackStart(img, false, false, 0)

	button.Connect("enter-notify-event", cancelClose)

	if len(instances) == 1 {
		button.Connect("event", func(btn *gtk.Button, e *gdk.Event) bool {
			btnEvent := gdk.EventButtonNewFromEvent(e)
			/* EVENT_BUTTON_PRESS would be more obvious, but it causes the misbehaviour:
			   if con is located on an external display, after pressing the button, the conID value
			   "freezes", and stays the same for all taskButtons, until the right mouse click.
			   A gotk3 bug or WTF? */
			if btnEvent.Type() == gdk.EVENT_BUTTON_RELEASE || btnEvent.Type() == gdk.EVENT_TOUCH_END {
				if btnEvent.Button() == 1 || btnEvent.Type() == gdk.EVENT_TOUCH_END {
					focusCon(t.conID)
					return true
				} else if btnEvent.Button() == 3 {
					contextMenu := taskMenuContext(t.ID, instances)
					contextMenu.PopupAtWidget(button, widgetAnchor, menuAnchor, nil)
					return true
				}
			}
			return false
		})
	} else {
		button.Connect("button-release-event", func(btn *gtk.Button, e *gdk.Event) bool {
			btnEvent := gdk.EventButtonNewFromEvent(e)
			if btnEvent.Button() == 1 {
				menu := taskMenu(t.ID, instances)
				menu.PopupAtWidget(button, widgetAnchor, menuAnchor, nil)
				return true
			} else if btnEvent.Button() == 3 {
				contextMenu := taskMenuContext(t.ID, instances)
				contextMenu.PopupAtWidget(button, widgetAnchor, menuAnchor, nil)
				return true
			}
			return false
		})
	}

	return box
}

func taskMenu(taskID string, instances []task) gtk.Menu {
	menu, _ := gtk.MenuNew()

	iconName, _ := getIcon(taskID)

	for _, instance := range instances {
		menuItem, _ := gtk.MenuItemNew()
		hbox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6)
		image, _ := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_MENU)
		hbox.PackStart(image, false, false, 0)
		title := instance.Name
		if len(title) > 20 {
			title = title[:20]
		}
		label, _ := gtk.LabelNew(fmt.Sprintf("%s (%v)", title, instance.WsNum))
		hbox.PackStart(label, false, false, 0)
		menuItem.Add(hbox)
		menu.Append(menuItem)
		conID := instance.conID
		menuItem.Connect("activate", func() {
			focusCon(conID)
		})

	}
	menu.ShowAll()
	return *menu
}

func taskMenuContext(taskID string, instances []task) gtk.Menu {
	menu, _ := gtk.MenuNew()

	iconName, err := getIcon(taskID)
	if err != nil {
		log.Warnf("%s %s", err, taskID)
	}
	for _, instance := range instances {
		menuItem, _ := gtk.MenuItemNew()
		hbox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6)
		image, _ := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_MENU)
		hbox.PackStart(image, false, false, 0)
		title := instance.Name
		if len(title) > 20 {
			title = title[:20]
		}
		label, _ := gtk.LabelNew(fmt.Sprintf("%s (%v)", title, instance.WsNum))
		hbox.PackStart(label, false, false, 0)
		menuItem.Add(hbox)
		menu.Append(menuItem)

		submenu, _ := gtk.MenuNew()
		subitem, _ := gtk.MenuItemNewWithLabel("Close")
		submenu.Append(subitem)

		conID := instance.conID
		subitem.Connect("activate", func() {
			killCon(conID)
		})
		for i := 1; i < int(*numWS)+1; i++ {
			subitem, _ := gtk.MenuItemNewWithLabel(fmt.Sprintf("To WS %v", i))
			target := i
			subitem.Connect("activate", func() {
				con2WS(conID, target)
			})
			submenu.Append(subitem)
		}

		menuItem.SetSubmenu(submenu)
	}
	separator, _ := gtk.SeparatorMenuItemNew()
	menu.Append(separator)

	item, _ := gtk.MenuItemNewWithLabel("New window")
	item.Connect("activate", func() {
		launch(taskID)
	})
	menu.Append(item)

	pinItem, _ := gtk.MenuItemNew()
	if !inPinned(taskID) {
		pinItem.SetLabel("Pin")
		pinItem.Connect("activate", func() {
			log.Infof("pin %s", taskID)
			pinTask(taskID)
		})
	} else {
		pinItem.SetLabel("Unpin")
		pinItem.Connect("activate", func() {
			log.Infof("unpin %s", taskID)
			unpinTask(taskID)
		})
	}
	menu.Append(pinItem)

	menu.ShowAll()
	return *menu
}

func inPinned(taskID string) bool {
	for _, id := range pinned {
		if strings.TrimSpace(taskID) == strings.TrimSpace(id) {
			return true
		}
	}
	return false
}

func inTasks(tasks []task, pinID string) bool {
	for _, task := range tasks {
		if strings.TrimSpace(task.ID) == strings.TrimSpace(pinID) {
			return true
		}
	}
	return false
}

func createImage(appID string, size int) (*gtk.Image, error) {
	name, err := getIcon(appID)
	if err != nil {
		name = appID
	}
	pixbuf, err := createPixbuf(name, size)
	if err != nil {
		return nil, err
	}
	image, _ := gtk.ImageNewFromPixbuf(pixbuf)

	return image, nil
}

func createPixbuf(icon string, size int) (*gdk.Pixbuf, error) {
	if strings.HasPrefix(icon, "/") {
		pixbuf, err := gdk.PixbufNewFromFileAtSize(icon, size, size)
		if err != nil {
			log.Errorf("%s", err)
			return nil, err
		}
		return pixbuf, nil
	}

	iconTheme, err := gtk.IconThemeGetDefault()
	if err != nil {
		log.Fatal("Couldn't get default theme: ", err)
	}
	pixbuf, err := iconTheme.LoadIcon(icon, size, gtk.ICON_LOOKUP_FORCE_SIZE)
	if err != nil {
		ico, err := getIcon(icon)
		if err != nil {
			return nil, err
		}

		if strings.HasPrefix(ico, "/") {
			pixbuf, err := gdk.PixbufNewFromFileAtSize(ico, size, size)
			if err != nil {
				return nil, err
			}
			return pixbuf, nil
		}

		pixbuf, err := iconTheme.LoadIcon(ico, size, gtk.ICON_LOOKUP_FORCE_SIZE)
		if err != nil {
			return nil, err
		}
		return pixbuf, nil
	}
	return pixbuf, nil
}

func cacheDir() string {
	if os.Getenv("XDG_CACHE_HOME") != "" {
		return os.Getenv("XDG_CONFIG_HOME")
	}
	if os.Getenv("HOME") != "" && pathExists(filepath.Join(os.Getenv("HOME"), ".cache")) {
		p := filepath.Join(os.Getenv("HOME"), ".cache")
		return p
	}
	return ""
}

func tempDir() string {
	if os.Getenv("TMPDIR") != "" {
		return os.Getenv("TMPDIR")
	} else if os.Getenv("TEMP") != "" {
		return os.Getenv("TEMP")
	} else if os.Getenv("TMP") != "" {
		return os.Getenv("TMP")
	}
	return "/tmp"
}

func readTextFile(path string) (string, error) {
	bytes, err := os.ReadFile(path)
	if err != nil {
		return "", err
	}

	return string(bytes), nil
}

func configDir() string {
	if os.Getenv("XDG_CONFIG_HOME") != "" {
		return (fmt.Sprintf("%s/nwg-dock", os.Getenv("XDG_CONFIG_HOME")))
	}
	return fmt.Sprintf("%s/.config/nwg-dock", os.Getenv("HOME"))
}

func createDir(dir string) {
	if _, err := os.Stat(dir); os.IsNotExist(err) {
		err := os.MkdirAll(dir, os.ModePerm)
		if err == nil {
			log.Infof("Creating dir: %s", dir)
		}
	}
}

func copyFile(src, dst string) error {
	log.Infof("Copying file: %s", dst)

	var err error
	var srcfd *os.File
	var dstfd *os.File
	var srcinfo os.FileInfo

	if srcfd, err = os.Open(src); err != nil {
		return err
	}
	defer srcfd.Close()

	if dstfd, err = os.Create(dst); err != nil {
		return err
	}
	defer dstfd.Close()

	if _, err = io.Copy(dstfd, srcfd); err != nil {
		return err
	}
	if srcinfo, err = os.Stat(src); err != nil {
		return err
	}
	return os.Chmod(dst, srcinfo.Mode())
}

func getDataHome() string {
	if os.Getenv("XDG_DATA_HOME") != "" {
		return os.Getenv("XDG_DATA_HOME")
	}
	return "/usr/share/"
}

func getAppDirs() []string {
	var dirs []string
	xdgDataDirs := ""

	home := os.Getenv("HOME")
	xdgDataHome := os.Getenv("XDG_DATA_HOME")
	if os.Getenv("XDG_DATA_DIRS") != "" {
		xdgDataDirs = os.Getenv("XDG_DATA_DIRS")
	} else {
		xdgDataDirs = "/usr/local/share/:/usr/share/"
	}
	if xdgDataHome != "" {
		dirs = append(dirs, filepath.Join(xdgDataHome, "applications"))
	} else if home != "" {
		dirs = append(dirs, filepath.Join(home, ".local/share/applications"))
	}
	for _, d := range strings.Split(xdgDataDirs, ":") {
		dirs = append(dirs, filepath.Join(d, "applications"))
	}
	flatpakDirs := []string{filepath.Join(home, ".local/share/flatpak/exports/share/applications"),
		"/var/lib/flatpak/exports/share/applications"}

	for _, d := range flatpakDirs {
		if !isIn(dirs, d) {
			dirs = append(dirs, d)
		}
	}
	return dirs
}

func isIn(slice []string, val string) bool {
	for _, item := range slice {
		if item == val {
			return true
		}
	}
	return false
}

func getIcon(appName string) (string, error) {
	appName = strings.Split(appName, " ")[0]
	if strings.HasPrefix(strings.ToUpper(appName), "GIMP") {
		return "gimp", nil
	}
	p := ""
	for _, d := range appDirs {
		path := filepath.Join(d, fmt.Sprintf("%s.desktop", appName))
		if pathExists(path) {
			p = path
			break
		} else if pathExists(strings.ToLower(path)) {
			p = strings.ToLower(path)
			break
		}
	}
	/* Some apps' app_id varies from their .desktop file name, e.g. 'gimp-2.9.9' or 'pamac-manager'.
	   Let's try to find a matching .desktop file name */
	if !strings.HasPrefix(appName, "/") && p == "" { // skip icon paths given instead of names
		p = searchDesktopDirs(appName)
	}

	if p != "" {
		lines, err := loadTextFile(p)
		if err != nil {
			return "", err
		}
		for _, line := range lines {
			if strings.HasPrefix(strings.ToUpper(line), "ICON") {
				return strings.Split(line, "=")[1], nil
			}
		}
	}
	return "", errors.New(fmt.Sprintf("couldn't find the icon for %s", appName))
}

func searchDesktopDirs(badAppID string) string {
	b4Separator := strings.Split(badAppID, "-")[0]
	for _, d := range appDirs {
		items, _ := os.ReadDir(d)
		for _, item := range items {
			if strings.Contains(item.Name(), b4Separator) {
				//Let's check items starting from 'org.' first
				if strings.Count(item.Name(), ".") > 1 && strings.HasSuffix(item.Name(),
					fmt.Sprintf("%s.desktop", badAppID)) {
					return filepath.Join(d, item.Name())
				}
			}
		}
	}
	// exceptions like "class": "VirtualBox Manager" & virtualbox.desktop
	b4Separator = strings.Split(badAppID, " ")[0]
	for _, d := range appDirs {
		items, _ := os.ReadDir(d)
		for _, item := range items {
			if strings.Contains(strings.ToUpper(item.Name()), strings.ToUpper(b4Separator)) {
				return filepath.Join(d, item.Name())
			}
		}
	}
	return ""
}

func getExec(appName string) (string, error) {
	cmd := appName
	if strings.HasPrefix(strings.ToUpper(appName), "GIMP") {
		cmd = "gimp"
	}
	for _, d := range appDirs {
		files, _ := os.ReadDir(d)
		path := ""
		for _, f := range files {
			if strings.HasSuffix(f.Name(), ".desktop") {
				if f.Name() == fmt.Sprintf("%s.desktop", appName) ||
					f.Name() == fmt.Sprintf("%s.desktop", strings.ToLower(appName)) {
					path = filepath.Join(d, f.Name())
					break
				}
			}
		}

		// as above in getIcon - for tasks w/ improper app_id
		if path == "" {
			path = searchDesktopDirs(appName)
		}

		if path != "" {
			lines, err := loadTextFile(path)
			if err != nil {
				return "", err
			}
			for _, line := range lines {
				if strings.HasPrefix(strings.ToUpper(line), "EXEC") {
					l := line[5:]
					cutAt := strings.Index(l, "%")
					if cutAt != -1 {
						l = l[:cutAt-1]
					}
					cmd = l
					break
				}
			}
			return cmd, nil
		}
	}
	return cmd, nil
}

func getName(appName string) string {
	name := appName
	for _, d := range appDirs {
		files, _ := os.ReadDir(d)
		path := ""
		for _, f := range files {
			if strings.HasSuffix(f.Name(), ".desktop") {
				if f.Name() == fmt.Sprintf("%s.desktop", appName) ||
					f.Name() == fmt.Sprintf("%s.desktop", strings.ToLower(appName)) {
					path = filepath.Join(d, f.Name())
					break
				}
			}
		}

		if path != "" {
			lines, err := loadTextFile(path)
			if err != nil {
				return name
			}
			for _, line := range lines {
				if strings.HasPrefix(strings.ToUpper(line), "NAME") {
					name = line[5:]
					break
				}
			}
		}
	}
	return name
}

func pathExists(name string) bool {
	if _, err := os.Stat(name); err != nil {
		if os.IsNotExist(err) {
			return false
		}
	}
	return true
}

func loadTextFile(path string) ([]string, error) {
	bytes, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	lines := strings.Split(string(bytes), "\n")
	var output []string
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if line != "" {
			output = append(output, line)
		}

	}
	return output, nil
}

func pinTask(itemID string) {
	for _, item := range pinned {
		if item == itemID {
			println(item, "already pinned")
			return
		}
	}
	pinned = append(pinned, itemID)
	savePinned()
	refreshMainBoxChannel <- struct{}{}
}

func unpinTask(itemID string) {
	pinned = remove(pinned, itemID)
	savePinned()
	refreshMainBoxChannel <- struct{}{}
}

func remove(s []string, r string) []string {
	for i, v := range s {
		if v == r {
			return append(s[:i], s[i+1:]...)
		}
	}
	return s
}

func savePinned() {
	f, err := os.OpenFile(pinnedFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
	if err != nil {
		log.Fatal(err)
	}

	defer f.Close()

	for _, line := range pinned {
		if line != "" {
			_, err := f.WriteString(line + "\n")

			if err != nil {
				log.Errorf("Error saving pinned", err)
			}
		}
	}
}

func launch(ID string) {
	command, err := getExec(ID)
	if err != nil {
		log.Errorf("%s", err)
	}
	// remove quotation marks if any
	if strings.Contains(command, "\"") {
		command = strings.ReplaceAll(command, "\"", "")
	}

	elements := strings.Split(command, " ")

	// find prepended env variables, if any
	envVarsNum := strings.Count(command, "=")
	var envVars []string

	cmdIdx := -1

	if envVarsNum > 0 {
		for idx, item := range elements {
			if strings.Contains(item, "=") {
				envVars = append(envVars, item)
			} else if !strings.HasPrefix(item, "-") && cmdIdx == -1 {
				cmdIdx = idx
			}
		}
	}
	if cmdIdx == -1 {
		cmdIdx = 0
	}
	var args []string
	for _, arg := range elements[1+cmdIdx:] {
		if !strings.Contains(arg, "=") {
			args = append(args, arg)
		}
	}

	cmd := exec.Command(elements[cmdIdx], elements[1+cmdIdx:]...)

	// set env variables
	if len(envVars) > 0 {
		cmd.Env = os.Environ()
		cmd.Env = append(cmd.Env, envVars...)
	}

	msg := fmt.Sprintf("env vars: %s; command: '%s'; args: %s\n", envVars, elements[cmdIdx], args)
	log.Info(msg)

	if err := cmd.Start(); err != nil {
		log.Error("Unable to launch command!", err.Error())
	}

	if *autohide {
		win.Hide()
	}
}

func focusCon(conID int64) {
	cmd := fmt.Sprintf("[con_id=%v] focus", conID)
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	client, err := sway.New(ctx)
	if err != nil {
		log.Panic(err)
	}
	if _, err = client.RunCommand(ctx, cmd); err != nil {
		log.Errorf("Unable to focus to con %v: %s", conID, err.Error())
	}

	if *autohide {
		src = glib.TimeoutAdd(uint(1000), func() bool {
			win.Hide()
			return false
		})
	}
}

func focusWorkspace(num int64) {
	cmd := fmt.Sprintf("workspace number %v", num)
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	client, err := sway.New(ctx)
	if err != nil {
		log.Panic(err)
	}
	if _, err = client.RunCommand(ctx, cmd); err != nil {
		log.Errorf("Unable to focus to workspace %v: %s", num, err.Error())
	}

	if *autohide {
		src = glib.TimeoutAdd(uint(1000), func() bool {
			win.Hide()
			return false
		})
	}
}

func killCon(conID int64) {
	cmd := fmt.Sprintf("[con_id=%v] kill", conID)
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	client, err := sway.New(ctx)
	if err != nil {
		log.Panic(err)
	}
	if _, err = client.RunCommand(ctx, cmd); err != nil {
		log.Errorf("Unable to kill con %v: %s", conID, err.Error())
	}

	if *autohide {
		src = glib.TimeoutAdd(uint(1000), func() bool {
			win.Hide()
			return false
		})
	}
}

func con2WS(conID int64, wsNum int) {
	cmd := fmt.Sprintf("[con_id=%v] move to workspace number %v", conID, wsNum)
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	client, err := sway.New(ctx)
	if err != nil {
		log.Panic(err)
	}

	if _, err = client.RunCommand(ctx, cmd); err != nil {
		log.Errorf("Unable to move to workspace %v: %s", wsNum, err.Error())
	}

	refreshMainBoxChannel <- struct{}{}

	if *autohide {
		src = glib.TimeoutAdd(uint(1000), func() bool {
			win.Hide()
			return false
		})
	}
}

// Returns map output name -> gdk.Monitor
func mapOutputs() (map[string]*gdk.Monitor, error) {
	result := make(map[string]*gdk.Monitor)

	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	client, err := sway.New(ctx)
	if err != nil {
		return nil, err
	}

	outputs, err := client.GetOutputs(ctx)
	if err != nil {
		return nil, err
	}

	display, err := gdk.DisplayGetDefault()
	if err != nil {
		return nil, err
	}

	num := display.GetNMonitors()
	for i := 0; i < num; i++ {
		monitor, _ := display.GetMonitor(i)
		geometry := monitor.GetGeometry()
		// assign output to monitor on the basis of the same x, y coordinates
		for _, output := range outputs {
			if int(output.Rect.X) == geometry.GetX() && int(output.Rect.Y) == geometry.GetY() {
				result[output.Name] = monitor
			}
		}
	}
	return result, nil
}

func listMonitors() ([]gdk.Monitor, error) {
	var monitors []gdk.Monitor
	display, err := gdk.DisplayGetDefault()
	if err != nil {
		return nil, err
	}

	num := display.GetNMonitors()
	for i := 0; i < num; i++ {
		monitor, _ := display.GetMonitor(i)
		monitors = append(monitors, *monitor)
	}
	return monitors, nil
}

// Returns output of a CLI command with optional arguments
func getCommandOutput(command string) string {
	out, err := exec.Command("sh", "-c", command).Output()
	if err != nil {
		return ""
	}

	return strings.TrimSpace(string(out))
}

func isCommand(command string) bool {
	cmd := strings.Fields(command)[0]
	return getCommandOutput(fmt.Sprintf("command -v %s ", cmd)) != ""
}

func md5Hash(text string) string {
	hash := md5.Sum([]byte(text))
	return hex.EncodeToString(hash[:])
}
