// Command deskpilot is a named workspace launcher: save a set of URLs,
// commands/programs, and file/folder paths under a name, then launch the
// whole set with a single command.
//
// DeskPilot's full product concept is a Windows desktop-workspace manager
// (monitor profiles, window rules, taskbar customization). Those features
// need native Win32/AppKit/X11 window-management APIs that are out of
// reach for a portable, dependency-free Go CLI, so this prototype scopes
// down to the cross-platform-compatible heart of the idea: reproducible
// workspaces of things-to-open. See ../plan.md for the full product plan
// and the roadmap items that need native platform APIs.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
)

// ---------------------------------------------------------------------
// Flag-reordering workaround
//
// Go's flag package stops parsing at the first positional argument, so a
// command line like `deskpilot save work --add url=...` would otherwise
// leave --add unparsed. reorderFlags walks the raw args and moves every
// recognized flag (and, for value flags, the value that follows it) to
// the front, leaving positional arguments at the end, before handing the
// result to flag.FlagSet.Parse.
// ---------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

// stringSliceFlag collects repeated occurrences of a flag, e.g.
// --add url=... --add command=... --add path=...
type stringSliceFlag []string

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

// ---------------------------------------------------------------------
// Workspace model
// ---------------------------------------------------------------------

// Item is one thing a workspace opens. Type is one of "url", "command",
// or "path". For "command", Target is the program to run and Args are
// its arguments. For "url" and "path", Target is the URL or filesystem
// path to open with the OS default handler; Args is unused.
type Item struct {
	Type   string   `json:"type"`
	Target string   `json:"target"`
	Args   []string `json:"args,omitempty"`
}

// Workspace is a named, ordered list of items to open together.
type Workspace struct {
	Name  string `json:"name"`
	Items []Item `json:"items"`
}

const defaultDirName = ".deskpilot"

func defaultDir() string {
	home, err := os.UserHomeDir()
	if err != nil {
		return defaultDirName
	}
	return filepath.Join(home, defaultDirName)
}

func workspacePath(dir, name string) string {
	return filepath.Join(dir, name+".json")
}

func loadWorkspace(dir, name string) (*Workspace, error) {
	path := workspacePath(dir, name)
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("workspace %q not found (looked for %s)", name, path)
		}
		return nil, fmt.Errorf("reading %s: %w", path, err)
	}
	var ws Workspace
	if err := json.Unmarshal(data, &ws); err != nil {
		return nil, fmt.Errorf("parsing %s: %w", path, err)
	}
	return &ws, nil
}

// ---------------------------------------------------------------------
// Opener: launch a URL or path with the OS default handler, or run a
// command directly. All of these are fire-and-forget: DeskPilot starts
// the process and moves on, it never waits for a GUI app (or a browser
// window) to close.
// ---------------------------------------------------------------------

// openerCommand returns the *exec.Cmd that would open target with the
// platform's default handler, without starting it.
func openerCommand(target string) *exec.Cmd {
	switch runtime.GOOS {
	case "windows":
		// The empty "" argument is required: `start` treats the first
		// quoted argument as a window title, not the thing to open.
		return exec.Command("cmd", "/C", "start", "", target)
	case "darwin":
		return exec.Command("open", target)
	default:
		return exec.Command("xdg-open", target)
	}
}

func openerDescription(target string) string {
	switch runtime.GOOS {
	case "windows":
		return fmt.Sprintf(`cmd /C start "" %s`, target)
	case "darwin":
		return fmt.Sprintf("open %s", target)
	default:
		return fmt.Sprintf("xdg-open %s", target)
	}
}

// launchDetached starts cmd without waiting for it to exit. Launch
// failures (e.g. the opener binary doesn't exist) are returned as an
// error rather than crashing the caller, since a headless machine
// legitimately may not have xdg-open/open/start available.
func launchDetached(cmd *exec.Cmd) error {
	cmd.Stdin = nil
	cmd.Stdout = nil
	cmd.Stderr = nil
	if err := cmd.Start(); err != nil {
		return err
	}
	// Fire-and-forget: release the OS process handle so we don't leak
	// resources waiting on something we never intend to Wait() on.
	return cmd.Process.Release()
}

// ---------------------------------------------------------------------
// main / dispatch
// ---------------------------------------------------------------------

func usage() {
	fmt.Fprintln(os.Stderr, `DeskPilot - named workspace launcher

Usage:
  deskpilot save <name> --add TYPE=TARGET [--add TYPE=TARGET ...] [--dir <workspace-dir>]
  deskpilot list [--dir <workspace-dir>]
  deskpilot open <name> [--dir <workspace-dir>] [--dry-run]
  deskpilot remove <name> [--dir <workspace-dir>] [--apply]
  deskpilot help

TYPE is one of: url, command, path
  --add url=https://example.com
  --add command="code /home/user/project"   (split on whitespace: program then args;
                                              quoting args-with-spaces is not supported)
  --add path=/home/user/Documents

Default workspace directory: ~/.deskpilot

"deskpilot open" launches real processes / opens real URLs and files with
your OS's default handler. Use --dry-run to preview what it would do
without launching anything.`)
}

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}

	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		return
	case "save":
		cmdSave(os.Args[2:])
	case "list":
		cmdList(os.Args[2:])
	case "open":
		cmdOpen(os.Args[2:])
	case "remove":
		cmdRemove(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "deskpilot: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

// ---------------------------------------------------------------------
// save
// ---------------------------------------------------------------------

func cmdSave(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			fmt.Println(`Usage: deskpilot save <name> --add TYPE=TARGET [--add TYPE=TARGET ...] [--dir <workspace-dir>]

Builds a workspace definition from one or more --add flags and writes it
as JSON to <workspace-dir>/<name>.json (default ~/.deskpilot, created if
missing).`)
			return
		}
	}

	args = reorderFlags(args, map[string]bool{"dir": true, "add": true})
	fs := flag.NewFlagSet("save", flag.ExitOnError)
	dir := fs.String("dir", defaultDir(), "workspace directory")
	var adds stringSliceFlag
	fs.Var(&adds, "add", "item to add, TYPE=TARGET (repeatable)")
	fs.Parse(args)

	positional := fs.Args()
	if len(positional) < 1 {
		fmt.Fprintln(os.Stderr, "deskpilot save: missing workspace name")
		fmt.Fprintln(os.Stderr, "usage: deskpilot save <name> --add TYPE=TARGET [--add TYPE=TARGET ...] [--dir <workspace-dir>]")
		os.Exit(1)
	}
	name := positional[0]

	if len(adds) == 0 {
		fmt.Fprintln(os.Stderr, "deskpilot save: at least one --add TYPE=TARGET is required")
		os.Exit(1)
	}

	var items []Item
	for _, spec := range adds {
		itemType, target, ok := strings.Cut(spec, "=")
		if !ok || itemType == "" || target == "" {
			fmt.Fprintf(os.Stderr, "deskpilot save: invalid --add value %q, expected TYPE=TARGET\n", spec)
			os.Exit(1)
		}
		switch itemType {
		case "url", "path":
			items = append(items, Item{Type: itemType, Target: target})
		case "command":
			fields := strings.Fields(target)
			if len(fields) == 0 {
				fmt.Fprintf(os.Stderr, "deskpilot save: empty command in --add value %q\n", spec)
				os.Exit(1)
			}
			items = append(items, Item{Type: "command", Target: fields[0], Args: fields[1:]})
		default:
			fmt.Fprintf(os.Stderr, "deskpilot save: unknown item type %q (want url, command, or path)\n", itemType)
			os.Exit(1)
		}
	}

	if err := os.MkdirAll(*dir, 0o755); err != nil {
		fmt.Fprintf(os.Stderr, "deskpilot save: creating workspace directory %s: %v\n", *dir, err)
		os.Exit(1)
	}

	ws := Workspace{Name: name, Items: items}
	data, err := json.MarshalIndent(ws, "", "  ")
	if err != nil {
		fmt.Fprintf(os.Stderr, "deskpilot save: encoding workspace: %v\n", err)
		os.Exit(1)
	}
	data = append(data, '\n')

	path := workspacePath(*dir, name)
	if err := os.WriteFile(path, data, 0o644); err != nil {
		fmt.Fprintf(os.Stderr, "deskpilot save: writing %s: %v\n", path, err)
		os.Exit(1)
	}

	fmt.Printf("Saved workspace %q to %s (%d item(s))\n", name, path, len(items))
}

// ---------------------------------------------------------------------
// list
// ---------------------------------------------------------------------

func cmdList(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			fmt.Println("Usage: deskpilot list [--dir <workspace-dir>]")
			return
		}
	}

	args = reorderFlags(args, map[string]bool{"dir": true})
	fs := flag.NewFlagSet("list", flag.ExitOnError)
	dir := fs.String("dir", defaultDir(), "workspace directory")
	fs.Parse(args)

	entries, err := os.ReadDir(*dir)
	if err != nil {
		if os.IsNotExist(err) {
			fmt.Printf("No workspace directory at %s (no workspaces saved yet)\n", *dir)
			return
		}
		fmt.Fprintf(os.Stderr, "deskpilot list: reading %s: %v\n", *dir, err)
		os.Exit(1)
	}

	var names []string
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
			continue
		}
		names = append(names, strings.TrimSuffix(e.Name(), ".json"))
	}
	sort.Strings(names)

	if len(names) == 0 {
		fmt.Printf("No workspaces found in %s\n", *dir)
		return
	}

	fmt.Printf("Workspaces in %s:\n", *dir)
	for _, name := range names {
		ws, err := loadWorkspace(*dir, name)
		if err != nil {
			fmt.Printf("  %-20s (error reading workspace: %v)\n", name, err)
			continue
		}
		fmt.Printf("  %-20s %d item(s)\n", ws.Name, len(ws.Items))
	}
}

// ---------------------------------------------------------------------
// open
// ---------------------------------------------------------------------

func cmdOpen(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			fmt.Println(`Usage: deskpilot open <name> [--dir <workspace-dir>] [--dry-run]

Loads <workspace-dir>/<name>.json and launches each item: URLs and paths
are opened with the OS default handler, commands are run detached (not
waited on). This launches real processes / opens real windows. Use
--dry-run to preview what would happen without launching anything.`)
			return
		}
	}

	args = reorderFlags(args, map[string]bool{"dir": true})
	fs := flag.NewFlagSet("open", flag.ExitOnError)
	dir := fs.String("dir", defaultDir(), "workspace directory")
	dryRun := fs.Bool("dry-run", false, "preview actions without launching anything")
	fs.Parse(args)

	positional := fs.Args()
	if len(positional) < 1 {
		fmt.Fprintln(os.Stderr, "deskpilot open: missing workspace name")
		fmt.Fprintln(os.Stderr, "usage: deskpilot open <name> [--dir <workspace-dir>] [--dry-run]")
		os.Exit(1)
	}
	name := positional[0]

	ws, err := loadWorkspace(*dir, name)
	if err != nil {
		fmt.Fprintf(os.Stderr, "deskpilot open: %v\n", err)
		os.Exit(1)
	}

	if len(ws.Items) == 0 {
		fmt.Printf("Workspace %q has no items.\n", ws.Name)
		return
	}

	if *dryRun {
		fmt.Printf("Dry run: workspace %q (%d item(s)) - nothing will be launched\n", ws.Name, len(ws.Items))
	} else {
		fmt.Printf("Opening workspace %q (%d item(s))\n", ws.Name, len(ws.Items))
	}

	var launched, failed int
	for i, item := range ws.Items {
		switch item.Type {
		case "url":
			if *dryRun {
				fmt.Printf("  [%d] [dry-run] would open URL: %s (%s)\n", i+1, item.Target, openerDescription(item.Target))
				continue
			}
			if err := launchDetached(openerCommand(item.Target)); err != nil {
				fmt.Printf("  [%d] FAILED  open URL %s: %v\n", i+1, item.Target, err)
				failed++
			} else {
				fmt.Printf("  [%d] OK      opened URL %s\n", i+1, item.Target)
				launched++
			}
		case "path":
			if *dryRun {
				fmt.Printf("  [%d] [dry-run] would open path: %s (%s)\n", i+1, item.Target, openerDescription(item.Target))
				continue
			}
			if err := launchDetached(openerCommand(item.Target)); err != nil {
				fmt.Printf("  [%d] FAILED  open path %s: %v\n", i+1, item.Target, err)
				failed++
			} else {
				fmt.Printf("  [%d] OK      opened path %s\n", i+1, item.Target)
				launched++
			}
		case "command":
			full := strings.TrimSpace(item.Target + " " + strings.Join(item.Args, " "))
			if *dryRun {
				fmt.Printf("  [%d] [dry-run] would run command: %s\n", i+1, full)
				continue
			}
			cmd := exec.Command(item.Target, item.Args...)
			if err := launchDetached(cmd); err != nil {
				fmt.Printf("  [%d] FAILED  run command %s: %v\n", i+1, full, err)
				failed++
			} else {
				fmt.Printf("  [%d] OK      ran command %s\n", i+1, full)
				launched++
			}
		default:
			fmt.Printf("  [%d] FAILED  unknown item type %q for target %q\n", i+1, item.Type, item.Target)
			if !*dryRun {
				failed++
			}
		}
	}

	if *dryRun {
		return
	}
	fmt.Printf("Summary: %d launched, %d failed\n", launched, failed)
}

// ---------------------------------------------------------------------
// remove
// ---------------------------------------------------------------------

func cmdRemove(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			fmt.Println(`Usage: deskpilot remove <name> [--dir <workspace-dir>] [--apply]

Deletes a saved workspace file. Without --apply this is a dry run that
only confirms the workspace exists and would be removed; pass --apply to
actually delete it.`)
			return
		}
	}

	args = reorderFlags(args, map[string]bool{"dir": true})
	fs := flag.NewFlagSet("remove", flag.ExitOnError)
	dir := fs.String("dir", defaultDir(), "workspace directory")
	apply := fs.Bool("apply", false, "actually remove the workspace file")
	fs.Parse(args)

	positional := fs.Args()
	if len(positional) < 1 {
		fmt.Fprintln(os.Stderr, "deskpilot remove: missing workspace name")
		fmt.Fprintln(os.Stderr, "usage: deskpilot remove <name> [--dir <workspace-dir>] [--apply]")
		os.Exit(1)
	}
	name := positional[0]
	path := workspacePath(*dir, name)

	if _, err := os.Stat(path); err != nil {
		if os.IsNotExist(err) {
			fmt.Fprintf(os.Stderr, "deskpilot remove: workspace %q not found (looked for %s)\n", name, path)
			os.Exit(1)
		}
		fmt.Fprintf(os.Stderr, "deskpilot remove: %v\n", err)
		os.Exit(1)
	}

	if !*apply {
		fmt.Printf("Dry run: workspace %q exists at %s and would be removed. Re-run with --apply to remove it.\n", name, path)
		return
	}

	if err := os.Remove(path); err != nil {
		fmt.Fprintf(os.Stderr, "deskpilot remove: removing %s: %v\n", path, err)
		os.Exit(1)
	}
	fmt.Printf("Removed workspace %q (%s)\n", name, path)
}
