// Command deskautomate is a file-watch triggered automation runner.
//
// Unlike the manually triggered tools in the Automation Desk line, deskautomate
// runs a polling watcher over one or more directories and fires rules when a
// file APPEARS or CHANGES. Watching is poll-based on purpose: the tool is
// standard-library only, so there is no inotify / ReadDirectoryChangesW here.
package main

import (
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"sort"
	"strings"
	"syscall"
	"time"
)

const (
	stateVersion   = 1
	runTimeout     = 60 * time.Second
	maxOutputChars = 2000
)

// ---------------------------------------------------------------------------
// Shared Techlosoft CLI helpers (identical across the tool line).
// ---------------------------------------------------------------------------

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...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Rules
// ---------------------------------------------------------------------------

// Rule is one watch-and-act instruction.
type Rule struct {
	Name       string `json:"name"`
	WatchDir   string `json:"watch_dir"`
	Match      string `json:"match"`
	Action     string `json:"action"`
	Dest       string `json:"dest,omitempty"`
	Command    string `json:"command,omitempty"`
	DebounceMs int    `json:"debounce_ms,omitempty"`
}

type ruleFile struct {
	Rules []Rule `json:"rules"`
}

// loadRules reads a rules file. It accepts either {"rules":[...]} or a bare
// JSON array of rules.
func loadRules(path string) ([]Rule, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	trimmed := strings.TrimSpace(string(raw))
	if trimmed == "" {
		return nil, errors.New("rules file is empty")
	}
	if strings.HasPrefix(trimmed, "[") {
		var rs []Rule
		if err := json.Unmarshal(raw, &rs); err != nil {
			return nil, fmt.Errorf("invalid JSON: %v", err)
		}
		return rs, nil
	}
	var rf ruleFile
	if err := json.Unmarshal(raw, &rf); err != nil {
		return nil, fmt.Errorf("invalid JSON: %v", err)
	}
	return rf.Rules, nil
}

// validateRules returns a list of human readable problems. An empty list means
// the rule set is usable.
func validateRules(rules []Rule) []string {
	var problems []string
	if len(rules) == 0 {
		problems = append(problems, "rules file contains no rules")
	}
	seen := map[string]bool{}
	for i, r := range rules {
		label := fmt.Sprintf("rule #%d", i+1)
		if r.Name != "" {
			label = fmt.Sprintf("rule #%d (%s)", i+1, r.Name)
		}
		if strings.TrimSpace(r.Name) == "" {
			problems = append(problems, label+": missing \"name\"")
		} else if seen[r.Name] {
			problems = append(problems, label+": duplicate rule name")
		} else {
			seen[r.Name] = true
		}
		if strings.TrimSpace(r.WatchDir) == "" {
			problems = append(problems, label+": missing \"watch_dir\"")
		}
		if strings.TrimSpace(r.Match) == "" {
			problems = append(problems, label+": missing \"match\" glob")
		} else if _, err := filepath.Match(r.Match, "probe"); err != nil {
			problems = append(problems, fmt.Sprintf("%s: malformed glob %q: %v", label, r.Match, err))
		}
		switch r.Action {
		case "move", "copy":
			if strings.TrimSpace(r.Dest) == "" {
				problems = append(problems, fmt.Sprintf("%s: action %q requires \"dest\"", label, r.Action))
			} else if r.WatchDir != "" && insideDir(r.WatchDir, r.Dest) {
				problems = append(problems, fmt.Sprintf("%s: \"dest\" %q is inside \"watch_dir\" %q (would re-trigger itself)", label, r.Dest, r.WatchDir))
			}
			if strings.TrimSpace(r.Command) != "" {
				problems = append(problems, fmt.Sprintf("%s: \"command\" is only used by action \"run\"", label))
			}
		case "run":
			if strings.TrimSpace(r.Command) == "" {
				problems = append(problems, label+": action \"run\" requires \"command\"")
			}
			if strings.TrimSpace(r.Dest) != "" {
				problems = append(problems, fmt.Sprintf("%s: \"dest\" is only used by actions \"move\"/\"copy\"", label))
			}
		case "":
			problems = append(problems, label+": missing \"action\" (want move|copy|run)")
		default:
			problems = append(problems, fmt.Sprintf("%s: unknown action %q (want move|copy|run)", label, r.Action))
		}
		if r.DebounceMs < 0 {
			problems = append(problems, label+": \"debounce_ms\" must not be negative")
		}
	}
	return problems
}

// insideDir reports whether child is dir itself or lives underneath it.
func insideDir(dir, child string) bool {
	a, err := filepath.Abs(dir)
	if err != nil {
		return false
	}
	b, err := filepath.Abs(child)
	if err != nil {
		return false
	}
	rel, err := filepath.Rel(a, b)
	if err != nil {
		return false
	}
	if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return false
	}
	return true
}

// ---------------------------------------------------------------------------
// Persisted state
// ---------------------------------------------------------------------------

// entry records what the watcher last saw for one (rule, file) pair.
type entry struct {
	Rule          string `json:"rule"`
	Path          string `json:"path"`
	Size          int64  `json:"size"`
	ModTimeNs     int64  `json:"mtime_ns"`
	Handled       bool   `json:"handled"`
	StableSinceNs int64  `json:"stable_since_ns"`
	FiredAt       string `json:"fired_at,omitempty"`
}

type stateDoc struct {
	Version int               `json:"version"`
	Updated string            `json:"updated"`
	Entries map[string]*entry `json:"entries"`
}

func stateKey(rule, path string) string { return rule + "\x00" + path }

func loadState(path string) (*stateDoc, error) {
	st := &stateDoc{Version: stateVersion, Entries: map[string]*entry{}}
	raw, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return st, nil
		}
		return st, err
	}
	if len(strings.TrimSpace(string(raw))) == 0 {
		return st, nil
	}
	var doc stateDoc
	if err := json.Unmarshal(raw, &doc); err != nil {
		return st, fmt.Errorf("state file %s is corrupt: %v", path, err)
	}
	if doc.Entries == nil {
		doc.Entries = map[string]*entry{}
	}
	doc.Version = stateVersion
	return &doc, nil
}

func saveState(path string, st *stateDoc) error {
	st.Version = stateVersion
	st.Updated = time.Now().Format(time.RFC3339Nano)
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return err
		}
	}
	buf, err := json.MarshalIndent(st, "", "  ")
	if err != nil {
		return err
	}
	buf = append(buf, '\n')
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, buf, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}

// ---------------------------------------------------------------------------
// Log records
// ---------------------------------------------------------------------------

type logRecord struct {
	Time       string `json:"time"`
	Rule       string `json:"rule"`
	Action     string `json:"action"`
	Event      string `json:"event"`
	Path       string `json:"path"`
	Size       int64  `json:"size"`
	Dest       string `json:"dest,omitempty"`
	Command    string `json:"command,omitempty"`
	ExitCode   *int   `json:"exit_code,omitempty"`
	Output     string `json:"output,omitempty"`
	DryRun     bool   `json:"dry_run"`
	OK         bool   `json:"ok"`
	Error      string `json:"error,omitempty"`
	DurationMs int64  `json:"duration_ms"`
}

type logWriter struct {
	f *os.File
}

func openLog(path string) (*logWriter, error) {
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return nil, err
		}
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return nil, err
	}
	return &logWriter{f: f}, nil
}

func (w *logWriter) write(rec logRecord) error {
	buf, err := json.Marshal(rec)
	if err != nil {
		return err
	}
	buf = append(buf, '\n')
	_, err = w.f.Write(buf)
	return err
}

func (w *logWriter) Close() error {
	if w == nil || w.f == nil {
		return nil
	}
	return w.f.Close()
}

// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------

func uniquePath(dir, base string) string {
	cand := filepath.Join(dir, base)
	if _, err := os.Lstat(cand); err != nil {
		return cand
	}
	ext := filepath.Ext(base)
	stem := strings.TrimSuffix(base, ext)
	for i := 1; i < 10000; i++ {
		cand = filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext))
		if _, err := os.Lstat(cand); err != nil {
			return cand
		}
	}
	return filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, time.Now().UnixNano(), ext))
}

func copyFile(src, dst string, mode os.FileMode) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		return err
	}
	if err := out.Sync(); err != nil {
		out.Close()
		return err
	}
	return out.Close()
}

// doMove relocates src into destDir. It never overwrites: a colliding name gets
// a "-1", "-2", ... suffix. Nothing is ever deleted except the source inode
// after a verified cross-device copy.
func doMove(src, destDir string, apply bool) (string, error) {
	base := filepath.Base(src)
	if !apply {
		return uniquePath(destDir, base), nil
	}
	if err := os.MkdirAll(destDir, 0o755); err != nil {
		return "", err
	}
	dst := uniquePath(destDir, base)
	err := os.Rename(src, dst)
	if err == nil {
		return dst, nil
	}
	if !errors.Is(err, syscall.EXDEV) {
		return "", err
	}
	info, statErr := os.Stat(src)
	if statErr != nil {
		return "", statErr
	}
	if err := copyFile(src, dst, info.Mode().Perm()); err != nil {
		return "", err
	}
	if err := os.Remove(src); err != nil {
		return dst, fmt.Errorf("copied to %s but could not remove source: %v", dst, err)
	}
	return dst, nil
}

func doCopy(src, destDir string, apply bool) (string, error) {
	base := filepath.Base(src)
	if !apply {
		return uniquePath(destDir, base), nil
	}
	if err := os.MkdirAll(destDir, 0o755); err != nil {
		return "", err
	}
	info, err := os.Stat(src)
	if err != nil {
		return "", err
	}
	dst := uniquePath(destDir, base)
	if err := copyFile(src, dst, info.Mode().Perm()); err != nil {
		return "", err
	}
	return dst, nil
}

// doRun executes the rule command, appending the matched file path as the final
// argument. The child also gets DESKAUTOMATE_FILE / DESKAUTOMATE_RULE in its
// environment.
func doRun(command, rule, path string, apply bool) (int, string, error) {
	fields := strings.Fields(command)
	if len(fields) == 0 {
		return 0, "", errors.New("empty command")
	}
	if !apply {
		return 0, "", nil
	}
	args := append(append([]string{}, fields[1:]...), path)
	ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
	defer cancel()
	cmd := exec.CommandContext(ctx, fields[0], args...)
	cmd.Env = append(os.Environ(),
		"DESKAUTOMATE_FILE="+path,
		"DESKAUTOMATE_RULE="+rule,
	)
	out, err := cmd.CombinedOutput()
	text := strings.TrimRight(string(out), "\n")
	if len(text) > maxOutputChars {
		text = text[:maxOutputChars] + "...[truncated]"
	}
	code := 0
	if cmd.ProcessState != nil {
		code = cmd.ProcessState.ExitCode()
	}
	if ctx.Err() != nil {
		return code, text, fmt.Errorf("command timed out after %s", runTimeout)
	}
	if err != nil {
		var ee *exec.ExitError
		if errors.As(err, &ee) {
			return code, text, fmt.Errorf("command exited with status %d", code)
		}
		return code, text, err
	}
	return code, text, nil
}

// ---------------------------------------------------------------------------
// Watcher
// ---------------------------------------------------------------------------

type scanned struct {
	path string
	base string
	size int64
	mod  time.Time
}

func scanDir(dir string) ([]scanned, error) {
	ents, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	var out []scanned
	for _, e := range ents {
		if e.IsDir() {
			continue
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		if !info.Mode().IsRegular() {
			continue
		}
		p := filepath.Join(dir, e.Name())
		if abs, err := filepath.Abs(p); err == nil {
			p = abs
		}
		out = append(out, scanned{path: p, base: e.Name(), size: info.Size(), mod: info.ModTime()})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path })
	return out, nil
}

type passStats struct {
	scanned int
	fired   int
	failed  int
	pending int
	skipped int
}

type watcher struct {
	rules []Rule
	apply bool
	log   *logWriter
	state *stateDoc
	out   io.Writer
	errW  io.Writer
}

// pass performs one full poll of every rule's watch directory.
func (w *watcher) pass() passStats {
	var st passStats
	now := time.Now()
	cache := map[string][]scanned{}
	live := map[string]bool{}

	for _, r := range w.rules {
		files, ok := cache[r.WatchDir]
		if !ok {
			var err error
			files, err = scanDir(r.WatchDir)
			if err != nil {
				fmt.Fprintf(w.errW, "warn: rule %q: cannot read watch_dir %s: %v\n", r.Name, r.WatchDir, err)
				files = nil
			}
			cache[r.WatchDir] = files
		}
		for _, f := range files {
			live[f.path] = true
			match, err := filepath.Match(r.Match, f.base)
			if err != nil || !match {
				continue
			}
			st.scanned++
			key := stateKey(r.Name, f.path)
			prev := w.state.Entries[key]
			modNs := f.mod.UnixNano()

			if prev != nil && prev.Handled && prev.Size == f.size && prev.ModTimeNs == modNs {
				st.skipped++
				continue
			}

			event := "created"
			if prev != nil {
				event = "modified"
			}

			stableSince := now.UnixNano()
			if prev != nil && prev.Size == f.size && prev.ModTimeNs == modNs {
				stableSince = prev.StableSinceNs
			}

			if r.DebounceMs > 0 {
				age := now.Sub(time.Unix(0, stableSince))
				if age < time.Duration(r.DebounceMs)*time.Millisecond {
					w.state.Entries[key] = &entry{
						Rule: r.Name, Path: f.path, Size: f.size, ModTimeNs: modNs,
						Handled: false, StableSinceNs: stableSince,
					}
					st.pending++
					fmt.Fprintf(w.out, "wait  [%s] %s still settling (%s, stable for %dms of %dms)\n",
						r.Name, f.path, humanBytes(f.size), age.Milliseconds(), r.DebounceMs)
					continue
				}
			}

			ok := w.fire(r, f, event, now)
			if ok {
				st.fired++
				w.state.Entries[key] = &entry{
					Rule: r.Name, Path: f.path, Size: f.size, ModTimeNs: modNs,
					Handled: true, StableSinceNs: stableSince,
					FiredAt: now.Format(time.RFC3339Nano),
				}
			} else {
				st.failed++
				w.state.Entries[key] = &entry{
					Rule: r.Name, Path: f.path, Size: f.size, ModTimeNs: modNs,
					Handled: false, StableSinceNs: stableSince,
				}
			}
		}
	}

	// Forget files that no longer exist so the state file cannot grow forever.
	for k, e := range w.state.Entries {
		if live[e.Path] {
			continue
		}
		if _, err := os.Lstat(e.Path); err != nil {
			delete(w.state.Entries, k)
		}
	}
	return st
}

// fire executes one rule against one file and writes exactly one log line.
// It returns true when the action succeeded (or was a successful dry run).
func (w *watcher) fire(r Rule, f scanned, event string, now time.Time) bool {
	start := time.Now()
	rec := logRecord{
		Time:   now.Format(time.RFC3339Nano),
		Rule:   r.Name,
		Action: r.Action,
		Event:  event,
		Path:   f.path,
		Size:   f.size,
		DryRun: !w.apply,
	}
	var actErr error
	switch r.Action {
	case "move":
		dest, err := doMove(f.path, r.Dest, w.apply)
		rec.Dest, actErr = dest, err
	case "copy":
		dest, err := doCopy(f.path, r.Dest, w.apply)
		rec.Dest, actErr = dest, err
	case "run":
		code, out, err := doRun(r.Command, r.Name, f.path, w.apply)
		rec.Command = r.Command + " " + f.path
		rec.Output = out
		actErr = err
		if w.apply {
			c := code
			rec.ExitCode = &c
		}
	default:
		actErr = fmt.Errorf("unknown action %q", r.Action)
	}
	rec.DurationMs = time.Since(start).Milliseconds()
	rec.OK = actErr == nil
	if actErr != nil {
		rec.Error = actErr.Error()
	}
	if w.log != nil {
		if err := w.log.write(rec); err != nil {
			fmt.Fprintf(w.errW, "warn: cannot append to log: %v\n", err)
		}
	}

	tag := "FIRE"
	if !w.apply {
		tag = "DRY "
	}
	if actErr != nil {
		fmt.Fprintf(w.errW, "FAIL  [%s] %s %s (%s): %v\n", r.Name, r.Action, f.path, humanBytes(f.size), actErr)
		return false
	}
	switch r.Action {
	case "move", "copy":
		fmt.Fprintf(w.out, "%s  [%s] %s %s (%s) -> %s\n", tag, r.Name, r.Action, f.path, humanBytes(f.size), rec.Dest)
	case "run":
		fmt.Fprintf(w.out, "%s  [%s] run %s %s (%s)\n", tag, r.Name, r.Command, f.path, humanBytes(f.size))
	}
	return true
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

func cmdWatch(argv []string) int {
	valueFlags := map[string]bool{"rules": true, "interval": true, "log": true, "state": true}
	argv = reorderFlags(argv, valueFlags)

	fs := flag.NewFlagSet("watch", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	rulesPath := fs.String("rules", "", "rules JSON file")
	intervalStr := fs.String("interval", "500ms", "poll interval")
	logPath := fs.String("log", "deskautomate.jsonl", "JSONL action log")
	statePath := fs.String("state", "", "handled-state file (default <log>.state.json)")
	once := fs.Bool("once", false, "single poll pass, then exit")
	apply := fs.Bool("apply", false, "actually perform actions")
	if err := fs.Parse(argv); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			usage(os.Stdout)
			return 0
		}
		fmt.Fprintf(os.Stderr, "deskautomate: %v\n\n", err)
		usage(os.Stderr)
		return 1
	}
	if *rulesPath == "" && fs.NArg() > 0 {
		*rulesPath = fs.Arg(0)
	} else if fs.NArg() > 0 {
		fmt.Fprintf(os.Stderr, "deskautomate: unexpected argument %q\n\n", fs.Arg(0))
		usage(os.Stderr)
		return 1
	}
	if *rulesPath == "" {
		fmt.Fprintf(os.Stderr, "deskautomate: watch needs --rules <file>\n\n")
		usage(os.Stderr)
		return 1
	}
	interval, err := time.ParseDuration(*intervalStr)
	if err != nil || interval <= 0 {
		fmt.Fprintf(os.Stderr, "deskautomate: bad --interval %q (want e.g. 500ms, 2s)\n\n", *intervalStr)
		usage(os.Stderr)
		return 1
	}
	if *statePath == "" {
		*statePath = *logPath + ".state.json"
	}

	rules, err := loadRules(*rulesPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "deskautomate: %v\n", err)
		return 1
	}
	if problems := validateRules(rules); len(problems) > 0 {
		fmt.Fprintf(os.Stderr, "deskautomate: %s is not valid:\n", *rulesPath)
		for _, p := range problems {
			fmt.Fprintf(os.Stderr, "  - %s\n", p)
		}
		return 1
	}

	state, err := loadState(*statePath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "deskautomate: %v\n", err)
		return 1
	}
	lw, err := openLog(*logPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "deskautomate: cannot open log %s: %v\n", *logPath, err)
		return 1
	}
	defer lw.Close()

	w := &watcher{rules: rules, apply: *apply, log: lw, state: state, out: os.Stdout, errW: os.Stderr}

	mode := "DRY RUN (no changes; add --apply to act)"
	if *apply {
		mode = "APPLY"
	}
	fmt.Printf("deskautomate watch: %d rule(s), interval %s, mode %s\n", len(rules), interval, mode)
	fmt.Printf("  rules %s\n  log   %s\n  state %s\n", *rulesPath, *logPath, *statePath)

	failed := 0
	runPass := func() {
		st := w.pass()
		failed += st.failed
		fmt.Printf("pass: %d matched, %d fired, %d failed, %d waiting, %d already handled\n",
			st.scanned, st.fired, st.failed, st.pending, st.skipped)
		if *apply {
			if err := saveState(*statePath, w.state); err != nil {
				fmt.Fprintf(os.Stderr, "warn: cannot save state: %v\n", err)
			}
		}
	}

	if *once {
		runPass()
		if failed > 0 {
			return 2
		}
		return 0
	}

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	runPass()
	for {
		select {
		case <-ticker.C:
			runPass()
		case s := <-sig:
			fmt.Printf("\nreceived %s, stopping watcher\n", s)
			if failed > 0 {
				return 2
			}
			return 0
		}
	}
}

func cmdRules(argv []string) int {
	valueFlags := map[string]bool{"rules": true}
	argv = reorderFlags(argv, valueFlags)

	fs := flag.NewFlagSet("rules", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	rulesPath := fs.String("rules", "", "rules JSON file")
	if err := fs.Parse(argv); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			usage(os.Stdout)
			return 0
		}
		fmt.Fprintf(os.Stderr, "deskautomate: %v\n\n", err)
		usage(os.Stderr)
		return 1
	}
	if *rulesPath == "" && fs.NArg() > 0 {
		*rulesPath = fs.Arg(0)
	}
	if *rulesPath == "" {
		fmt.Fprintf(os.Stderr, "deskautomate: rules needs --rules <file>\n\n")
		usage(os.Stderr)
		return 1
	}

	rules, err := loadRules(*rulesPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "deskautomate: cannot load %s: %v\n", *rulesPath, err)
		return 1
	}
	problems := validateRules(rules)
	if len(problems) > 0 {
		fmt.Fprintf(os.Stderr, "deskautomate: %s is not valid (%d problem(s)):\n", *rulesPath, len(problems))
		for _, p := range problems {
			fmt.Fprintf(os.Stderr, "  - %s\n", p)
		}
		return 1
	}

	fmt.Printf("%s: %d rule(s), all valid\n\n", *rulesPath, len(rules))
	for i, r := range rules {
		note := ""
		if info, err := os.Stat(r.WatchDir); err != nil {
			note = "  (watch_dir does not exist yet)"
		} else if !info.IsDir() {
			note = "  (watch_dir is not a directory)"
		}
		fmt.Printf("%d. %s\n", i+1, r.Name)
		fmt.Printf("   watch  %s%s\n", r.WatchDir, note)
		fmt.Printf("   match  %s\n", r.Match)
		switch r.Action {
		case "move", "copy":
			fmt.Printf("   action %s -> %s\n", r.Action, r.Dest)
		case "run":
			fmt.Printf("   action run: %s <matched-file>\n", r.Command)
		}
		if r.DebounceMs > 0 {
			fmt.Printf("   settle %dms of unchanged size+mtime before firing\n", r.DebounceMs)
		}
		fmt.Println()
	}
	return 0
}

func usage(w io.Writer) {
	fmt.Fprint(w, `deskautomate - file-watch triggered automation (Automation Desk line)

Watches directories by POLLING and fires rules when a file appears or changes.
Actions are a DRY RUN unless you pass --apply.

USAGE
  deskautomate <command> [flags]

COMMANDS
  watch    poll the watched directories and fire matching rules
  rules    validate and list the rules in a rules file
  help     show this help

WATCH FLAGS
  --rules <file>     rules JSON file (required; may also be given positionally)
  --interval <dur>   poll interval, e.g. 500ms, 2s        (default 500ms)
  --once             do a single poll pass and exit
  --apply            actually perform the actions         (default: dry run)
  --log <file>       JSONL log, one line per fired rule   (default deskautomate.jsonl)
  --state <file>     handled-file state                   (default <log>.state.json)

RULES FLAGS
  --rules <file>     rules JSON file (may also be given positionally)

RULES FILE
  {"rules": [
    {"name": "file-pdfs", "watch_dir": "/home/me/Inbox", "match": "*.pdf",
     "action": "move", "dest": "/home/me/Documents/PDF", "debounce_ms": 750},
    {"name": "index-csv", "watch_dir": "/home/me/Inbox", "match": "*.csv",
     "action": "run", "command": "/usr/local/bin/index.sh"}
  ]}

  action move|copy  needs "dest" (a directory; never overwrites, never deletes)
  action run        needs "command"; the matched file path is appended as the
                    final argument, and is also exported as DESKAUTOMATE_FILE

EXAMPLES
  deskautomate rules --rules rules.json
  deskautomate watch --rules rules.json --once
  deskautomate watch --rules rules.json --once --apply --log run.jsonl
  deskautomate watch --rules rules.json --interval 2s --apply --log run.jsonl

EXIT CODES
  0  success        1  usage / validation error        2  an action failed
`)
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// 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.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		usage(os.Stdout)
		os.Exit(0)
	case "watch":
		os.Exit(cmdWatch(args[1:]))
	case "rules":
		os.Exit(cmdRules(args[1:]))
	default:
		fmt.Fprintf(os.Stderr, "deskautomate: unknown command %q\n\n", args[0])
		usage(os.Stderr)
		os.Exit(1)
	}
}
