// package main is the package for the gendesk utility
package main

import (
	"bytes"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"text/template"

	"github.com/xyproto/env/v2"
	"github.com/xyproto/files"
	"github.com/xyproto/vt"
)

// WMStarter contains the information needed to generate
// a .desktop file for a Window Manager
type WMStarter struct {
	Name, Exec string
}

// AppStarter contains the information needed to generate
// a .desktop file for an application
type AppStarter struct {
	Name, GenericName, Comment, Exec, Icon, Path string
	CategoryList, MimeTypesList                  string
	UseTerminal, StartupNotify                   bool
}

// DesktopConfig bundles all the inputs needed to write a single .desktop file.
// It is built per-pkgname inside main and passed to the writer functions.
type DesktopConfig struct {
	Pkgname       string
	Name          string
	Comment       string
	Exec          string
	Icon          string
	Path          string
	Categories    string
	GenericName   string
	MimeTypes     string
	Custom        string
	Output        string // output filename, empty means PKGNAME.desktop
	UseTerminal   bool
	StartupNotify bool
	Force         bool
}

// desktopFilename returns the output filename for the .desktop file,
// falling back to PKGNAME.desktop when no explicit output was given
func (c *DesktopConfig) desktopFilename() string {
	if c.Output != "" {
		return c.Output
	}
	return c.Pkgname + ".desktop"
}

const (
	versionString = "Desktop File Generator 1.0.15"

	versionHelp       = "Show application name and version"
	nodownloadHelp    = "Don't download anything"
	nocolorHelp       = "Don't use colors"
	quietHelp         = "Don't output anything on stdout"
	forceHelp         = "Overwrite .desktop files with the same name"
	windowmanagerHelp = "Generate a .desktop file for launching a window manager"
	pkgnameHelp       = "The name of the package"
	pkgdescHelp       = "Description of the package"
	pathHelp          = "Starting directory"
	nameHelp          = "Name of the shortcut"
	genericnameHelp   = "Type of application"
	commentHelp       = "Shortcut comment"
	execHelp          = "Path to executable"
	terminalHelp      = "Run the application in a terminal (default is false)"
	categoriesHelp    = "Categories, see other .desktop files for examples"
	mimetypesHelp     = "Mime types, see other .desktop files for examples"
	startupnotifyHelp = "Notification when the application starts (default is false)"
	customHelp        = "Custom line to append at the end of the .desktop file"
	iconHelp          = "Specify a filename that will be used for the icon"
	outputHelp        = "Output .desktop filename, or comma-separated list (one per pkgname) for split PKGBUILDs (defaults to PKGNAME.desktop)"

	defaultPKGBUILD = "../PKGBUILD"
)

var (
	// Template for a .desktop file for starting a Window Manager
	wmTemplate, _ = template.New("WMStarter").Parse("[Desktop Entry]\nType=XSession\nExec={{.Exec}}\nTryExec={{.Exec}}\nName={{.Name}}\n")

	// Template for a .desktop file for starting an application
	appTemplate, _ = template.New("AppStarter").Parse("[Desktop Entry]\nVersion=1.0\nType=Application\nName={{.Name}}\n{{if .GenericName}}GenericName={{.GenericName}}\n{{end}}Comment={{.Comment}}\nExec={{.Exec}}\nIcon={{.Icon}}{{if .Path}}\nPath={{.Path}}{{end}}\nTerminal={{if .UseTerminal}}true{{else}}false{{end}}\nStartupNotify={{if .StartupNotify}}true{{else}}false{{end}}\nCategories={{.CategoryList}};\n{{if .MimeTypesList}}MimeType={{.MimeTypesList}};\n{{end}}")
)

// Generate the contents for the .desktop file (for executing a window manager)
func createWindowManagerDesktopContents(name, execCommand string) (*bytes.Buffer, error) {
	var buf bytes.Buffer
	// Insert strings into the template by using the WMStarter struct
	if err := wmTemplate.Execute(&buf, WMStarter{name, execCommand}); err != nil {
		return nil, err
	}
	return &buf, nil
}

// Generate the contents for the .desktop file (for executing a desktop application)
func createDesktopContents(name, genericName, comment, execCommand, icon, path string, useTerminal, startupNotify bool, categories, mimeTypes []string) (*bytes.Buffer, error) {
	var (
		appStarter = AppStarter{
			Name:          name,
			GenericName:   genericName,
			Comment:       comment,
			Exec:          execCommand,
			Icon:          icon,
			Path:          path,
			CategoryList:  strings.Join(categories, ";"),
			MimeTypesList: strings.Join(mimeTypes, ";"),
			UseTerminal:   useTerminal,
			StartupNotify: startupNotify,
		}
		buf bytes.Buffer
	)
	// Insert strings into the template by using an AppStarter variable
	if err := appTemplate.Execute(&buf, appStarter); err != nil {
		return nil, err
	}
	return &buf, nil
}

// Write the .desktop file as generated by createWindowManagerDesktopContents
func writeWindowManagerDesktopFile(cfg *DesktopConfig, o *vt.TextOutput) {
	buf, err := createWindowManagerDesktopContents(cfg.Name, cfg.Exec)
	if err != nil {
		o.Err("no")
		o.Eprintf("internal error when executing template: %v\n", err)
		os.Exit(1)
	}
	if cfg.Custom != "" {
		// Write the custom string to the end of the .desktop file (may contain \n)
		buf.WriteString(cfg.Custom + "\n")
	}
	filename := cfg.desktopFilename()
	// Check if the file exists (and that force is not enabled)
	if files.Exists(filename) && !cfg.Force {
		o.Err("no")
		o.Eprintf("%s already exists. Use -f as the first argument to overwrite it.\n", filename)
		os.Exit(1)
	}
	os.WriteFile(filename, buf.Bytes(), 0644)
}

// Write the .desktop file as generated by createDesktopContents
func writeDesktopFile(cfg *DesktopConfig, o *vt.TextOutput) {
	var categoryList, mimeTypeList []string

	if len(cfg.Categories) == 0 {
		categoryList = []string{"Application"}
	} else {
		categoryList = strings.Split(cfg.Categories, ";")
	}

	if err := ValidCategoryWords(categoryList); err != nil {
		o.Println(err)
	}

	// mimeTypeList is an empty []string, or...
	if len(cfg.MimeTypes) != 0 {
		mimeTypeList = strings.Split(cfg.MimeTypes, ";")
	}

	// Use the pkgname as the icon name if no icon is specified
	icon := cfg.Icon
	if icon == "" {
		icon = cfg.Pkgname
	}

	// mimeTypes may be empty. Disabled terminal
	// and startupnotify for now.
	buf, err := createDesktopContents(cfg.Name, cfg.GenericName, cfg.Comment, cfg.Exec, icon, cfg.Path, cfg.UseTerminal, cfg.StartupNotify, categoryList, mimeTypeList)
	if err != nil {
		o.Err("no")
		o.Eprintf("internal error when executing template: %v\n", err)
		os.Exit(1)
	}
	if cfg.Custom != "" {
		// Write the custom string to the end of the .desktop file (may contain \n)
		buf.WriteString(cfg.Custom + "\n")
	}

	filename := cfg.desktopFilename()

	// Check if the file exists (and that force is not enabled)
	if files.Exists(filename) && !cfg.Force {
		o.Err("no")
		o.Eprintf("%s already exists. Use -f as the first argument to overwrite it.\n", filename)
		os.Exit(1)
	}

	os.WriteFile(filename, buf.Bytes(), 0644)
}

// progress prints a "[pkgname]<padding>message... " progress line through o,
// honoring both --quiet (via o) and --nocolor (via tag substitution)
func progress(o *vt.TextOutput, pkgname, message string) {
	const nSpaces = 32
	spaces := strings.Repeat(" ", nSpaces)[:nSpaces-min(nSpaces, len(pkgname))]
	o.Printf("<darkgray>[</darkgray><lightblue>%s</lightblue><darkgray>]</darkgray>%s<darkgray>%s</darkgray> ", pkgname, spaces, message)
}

// keywordsInDescription checks if the given package description contains one of the given keywords
func keywordsInDescription(pkgdesc string, keywords []string) bool {
	for _, keyword := range keywords {
		if has(pkgdesc, keyword) {
			return true
		}
	}
	return false
}

// WriteDefaultIconFile copies /usr/share/pixmaps/gendesk.png to pkgname + ".png"
func WriteDefaultIconFile(pkgname string, o *vt.TextOutput) error {
	const defaultIconFilename = "/usr/share/pixmaps/gendesk.png"
	b, err := os.ReadFile(defaultIconFilename)
	if err != nil {
		o.Err("could not read " + defaultIconFilename + "!")
		return err
	}
	filename := pkgname + ".png"
	if err := os.WriteFile(filename, b, 0644); err != nil {
		o.Err("could not write icon to " + filename + "!")
		return err
	}
	return nil
}

func usage() {
	shortname := strings.Split(defaultIconSearchURL, "/")
	firstpart := "INVALID ICON SEARCH URL"
	if len(shortname) >= 3 {
		firstpart = strings.Join(shortname[:3], "/")
	}
	fmt.Println(`
` + versionString + `
Generate .desktop files.

Syntax: gendesk [flags]

Possible flags:
    --version                    ` + versionHelp + `
    -n                           ` + nodownloadHelp + `
    --nocolor                    ` + nocolorHelp + `
    -q                           ` + quietHelp + `
    -f                           ` + forceHelp + `
    -wm                          ` + windowmanagerHelp + `
    --pkgname=PKGNAME            ` + pkgnameHelp + `
    --pkgdesc=PKGDESC            ` + pkgdescHelp + `
    --path=PATH                  ` + pathHelp + `
    --name=NAME                  ` + nameHelp + `
    --genericname=GENERICNAME    ` + genericnameHelp + `
    --comment=COMMENT            ` + commentHelp + `
    --exec=EXEC                  ` + execHelp + `
    --icon=FILENAME              ` + iconHelp + `
    --terminal=[true|false]      ` + terminalHelp + `
    --categories=CATEGORIES      ` + categoriesHelp + `
    --mimetypes=MIMETYPES        ` + mimetypesHelp + `
    --startupnotify=[true|false] ` + startupnotifyHelp + `
    --custom=CUSTOM              ` + customHelp + `
    -o, --output=FILENAME        ` + outputHelp + `
    --help                       This text

Note:
    * Just providing a package name is enough to generate a .desktop file.
    * Providing a PKGBUILD filename instead of flags is a possibility.
    * "$startdir/PKGBUILD" is the default PKGBUILD filename.
    * _exec in the PKGBUILD can be used to specify a different executable for
      the .desktop file. Example: _exec=('appname-gui')
    * Split PKGBUILD packages are supported.
    * If a .png, .svg or .xpm icon is not found as a file or in the PKGBUILD,
      an icon will be downloaded from either the location specified in the
      configuration or from: ` + firstpart + `
      (This may or may not result in the icon you wished for).
    * Categories are guessed from keywords in the package description,
      unless specified.
    * Icons are assumed to be found in "/usr/share/pixmaps/" once installed.
    * Adding translations for many languages is not supported by gendesk,
      but it is possible to first generate a file, and then add translations.
`)
}

func main() {
	flag.Usage = usage

	var (
		version       = flag.Bool("version", false, versionHelp)
		nodownload    = flag.Bool("n", false, nodownloadHelp)
		nocolor       = flag.Bool("nocolor", false, nocolorHelp)
		quiet         = flag.Bool("q", false, quietHelp)
		force         = flag.Bool("f", false, forceHelp)
		windowmanager = flag.Bool("wm", false, windowmanagerHelp)
		givenPkgname  = flag.String("pkgname", "", pkgnameHelp)
		givenPkgdesc  = flag.String("pkgdesc", "", pkgdescHelp)
		name          = flag.String("name", "", nameHelp)
		path          = flag.String("path", "", pathHelp)
		genericname   = flag.String("genericname", "", genericnameHelp)
		comment       = flag.String("comment", "", commentHelp)
		execCommand   = flag.String("exec", "", execHelp)
		icon          = flag.String("icon", "", iconHelp)
		terminal      = flag.Bool("terminal", false, terminalHelp)
		categories    = flag.String("categories", "", categoriesHelp)
		mimetypes     = flag.String("mimetypes", "", mimetypesHelp)
		mimetype      = flag.String("mimetype", "", mimetypesHelp)
		custom        = flag.String("custom", "", customHelp)
		startupnotify = flag.Bool("startupnotify", false, startupnotifyHelp)
		output        = flag.String("output", "", outputHelp)
		o2            = flag.String("o", "", outputHelp)

		manualIconurl string
		filename      string
		pkgnames      []string
		iconurl       string

		// Per-pkgname data collected from PKGBUILD, env and flags
		pkgInfoMap = make(map[string]*PkgInfo)
	)

	// Parse flags, but allow them to appear after positional arguments too
	// (Go's flag package stops at the first non-flag by default).
	flag.Parse()
	var args []string
	for {
		remaining := flag.Args()
		if len(remaining) == 0 {
			break
		}
		args = append(args, remaining[0])
		flag.CommandLine.Parse(remaining[1:])
	}

	var (
		pkgname = *givenPkgname
		pkgdesc = *givenPkgdesc

		// New text output struct.
		// The first bool is if color should be enabled or disabled.
		// The second bool is if any output should be enabled at all.
		o = vt.NewTextOutput(!*nocolor, !*quiet)
	)

	// Output the version number and quit if --version is given
	if *version {
		o.Println(versionString)
		return
	}

	// TODO: Write in a cleaner way, possibly by refactoring into a function. Write a test first.
	if pkgname == "" {
		if len(args) == 0 {
			if envPkgname := env.Str("pkgname"); envPkgname == "" {
				if envSrcdest := env.Str("SRCDEST"); envSrcdest != "" {
					// If SRCDEST is set, use that
					filename = filepath.Join(envSrcdest, "PKGBUILD")
				} else {
					filename = defaultPKGBUILD
				}
			} else {
				pkgname = envPkgname
			}
		} else {
			// args are non-flag arguments
			filename = args[0]
		}
	}

	// Environment variables
	dataFromEnvironment(&pkgdesc, execCommand, name, genericname, mimetypes, comment, categories, custom)

	// Strip the "-bin", "-git", "-hg" or "-svn" suffix from the name, if present
	for _, suf := range []string{"bin", "git", "hg", "svn"} {
		pkgname = strings.TrimSuffix(pkgname, "-"+suf)
	}

	if filename != "" {
		// Check if the given filename is found
		if !files.Exists(filename) {
			// If --pkgname is not given and the file does not exist, use the base name as the pkgname
			pkgname = filepath.Base(filename)
			// Clear the filename variable, since the file was not found
			filename = ""
		} else {
			parsePKGBUILD(o, filename, &iconurl, &pkgname, &pkgnames, pkgInfoMap)
		}
	}

	// Either --output or -o may be given, prefer --output if both are set.
	// A comma-separated list is allowed to pair one filename per package
	// in a split PKGBUILD.
	outputFilename := *output
	if outputFilename == "" {
		outputFilename = *o2
	}
	var outputFilenames []string
	if outputFilename != "" {
		for _, s := range strings.Split(outputFilename, ",") {
			outputFilenames = append(outputFilenames, strings.TrimSpace(s))
		}
	}

	// Normalize pkgnames for the single-package case (no PKGBUILD, or a
	// PKGBUILD that didn't populate pkgnames for some reason).
	if len(pkgnames) == 0 {
		pkgnames = []string{pkgname}
	}

	// When --output names more than one file, or pairs a single name with a
	// split-package PKGBUILD, require the counts to match exactly.
	if len(outputFilenames) > 1 || (len(outputFilenames) == 1 && len(pkgnames) > 1) {
		if len(outputFilenames) != len(pkgnames) {
			o.ErrExit(fmt.Sprintf(
				"--output lists %d filename(s) but there are %d package(s) (%s)",
				len(outputFilenames), len(pkgnames), strings.Join(pkgnames, ", ")))
		}
		// Keep the full pkgnames list so each package gets its own output
	} else {
		// Preserve the historical single-package behavior: only the current
		// pkgname is generated, even if the PKGBUILD lists multiple packages.
		if len(pkgnames) > 1 && len(outputFilenames) == 1 {
			o.Eprintf("warning: --output specifies 1 filename but there are %d packages, only generating for %s\n", len(pkgnames), pkgname)
		}
		pkgnames = []string{pkgname}
	}

	// Set a PkgInfo field if the given value is not an empty string
	setv := func(field *string, value string) {
		if value != "" {
			*field = value
		}
	}

	noExecSpecified := *execCommand == ""

	info := ensurePkgInfo(pkgInfoMap, pkgname)
	setv(&info.Pkgdesc, pkgdesc)
	setv(&info.Exec, *execCommand)
	setv(&info.Name, *name)
	setv(&info.GenericName, *genericname)
	setv(&info.MimeTypes, *mimetype)
	setv(&info.MimeTypes, *mimetypes)
	setv(&info.Comment, *comment)
	setv(&info.Categories, *categories)
	setv(&info.Custom, *custom)

	// Write .desktop and .png icon for each package
	for i, pkgname := range pkgnames {
		if strings.Contains(pkgname, "-nox") || strings.Contains(pkgname, "-cli") {
			// Don't bother if it's a -nox or -cli package
			continue
		}

		info := ensurePkgInfo(pkgInfoMap, pkgname)

		// Apply fallbacks for fields that may be empty
		pkgdesc := info.Pkgdesc
		if pkgdesc == "" {
			// Fall back on the package name
			pkgdesc = pkgname
		}
		execCommand := info.Exec
		if execCommand == "" {
			// Fall back on the package name
			execCommand = pkgname
		}
		name := info.Name
		if name == "" {
			// Fall back on the capitalized package name
			name = capitalize(pkgname)
		}
		comment := info.Comment
		if comment == "" {
			// Fall back on pkgdesc
			comment = pkgdesc
		}
		categories := info.Categories
		if categories == "" {
			// Guess from keywords in the description
			categories = GuessCategory(pkgdesc)
		}

		// For the "Email" category: add "%u" to exec, if no exec command has been specified
		if strings.Contains(categories, "Email") && noExecSpecified && !strings.HasSuffix(execCommand, "%u") {
			// %u is added to be able to open mailto: links with e-mail applications
			execCommand += " %u"
		}

		// Pick the per-package output filename: index into the comma-split
		// list when one was given, otherwise the single value (or "" for the
		// default PKGNAME.desktop fallback).
		perPkgOutput := ""
		if i < len(outputFilenames) {
			perPkgOutput = outputFilenames[i]
		}

		cfg := &DesktopConfig{
			Pkgname:       pkgname,
			Name:          name,
			Comment:       comment,
			Exec:          execCommand,
			Icon:          *icon,
			Path:          *path,
			Categories:    categories,
			GenericName:   info.GenericName,
			MimeTypes:     info.MimeTypes,
			Custom:        info.Custom,
			Output:        perPkgOutput,
			UseTerminal:   *terminal,
			StartupNotify: *startupnotify,
			Force:         *force,
		}

		progress(o, pkgname, "Generating desktop file...")

		if *windowmanager {
			writeWindowManagerDesktopFile(cfg, o)
		} else {
			writeDesktopFile(cfg, o)
		}

		o.Printf("<green>ok</green>\n")

		// TODO: Refactor into a function
		// Download an icon if it's not downloaded by
		// the PKGBUILD and not there already (.png, .svg or .xpm)
		pngFilenames, _ := filepath.Glob("*.png")
		svgFilenames, _ := filepath.Glob("*.svg")
		xpmFilenames, _ := filepath.Glob("*.xpm")
		if (len(pngFilenames)+len(svgFilenames)+len(xpmFilenames) == 0) && !*nodownload {
			if len(pkgname) < 1 {
				o.Err("No pkgname, can't download icon")
			}
			progress(o, pkgname, "Downloading icon...")
			var err error
			if manualIconurl == "" {
				err = WriteIconFile(pkgname, o, *force)
			} else {
				// Default filename
				iconFilename := pkgname + ".png"
				// Get the last part of the URL, after the "/" to use as the filename
				if strings.Contains(manualIconurl, "/") {
					pos := strings.LastIndex(manualIconurl, "/")
					iconFilename = manualIconurl[pos+1:]
				}
				MustDownloadFile(manualIconurl, iconFilename, o, *force)
			}
			if err == nil {
				o.Printf("<lightcyan>ok</lightcyan>\n")
			} else {
				o.Printf("<yellow>no</yellow>\n")
				progress(o, pkgname, "Using default icon instead...")
				if err := WriteDefaultIconFile(pkgname, o); err == nil {
					o.Printf("<lightmagenta>yes</lightmagenta>\n")
				}
			}
		}
	}
}
